Skip to content

Commit 27aa56e

Browse files
authored
🤖 fix: preserve review formatting in queued messages (#1149)
## Summary Previously, reviews in queued messages were displayed as raw text with `<review>` 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 schema - Extract shared `UserMessageContent` component for rendering user messages - `MessageQueue.getReviews()` exposes reviews from stored metadata - Add `QueuedMessageWithReviews` story to cover the new functionality ## Refactoring The `UserMessageContent` extraction deduplicates ~60 lines of code between `UserMessage` and `QueuedMessage` components. Both components now use the shared component with a `variant` prop to control styling differences. ## Testing - Added story: `App/Reviews/QueuedMessageWithReviews` - Existing `MessageQueue` tests continue to pass - All static checks pass --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 020a17b commit 27aa56e

File tree

10 files changed

+212
-81
lines changed

10 files changed

+212
-81
lines changed

src/browser/components/Messages/QueuedMessage.tsx

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useCallback, useState } from "react";
22
import type { ButtonConfig } from "./MessageWindow";
33
import { MessageWindow } from "./MessageWindow";
4+
import { UserMessageContent } from "./UserMessageContent";
45
import type { QueuedMessage as QueuedMessageType } from "@/common/types/message";
56
import { Pencil } from "lucide-react";
67
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
@@ -61,32 +62,19 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = ({
6162
);
6263

6364
return (
64-
<>
65-
<MessageWindow
66-
label={queuedLabel}
67-
variant="user"
68-
message={message}
69-
className={className}
70-
buttons={buttons}
71-
>
72-
{content && (
73-
<pre className="text-subtle m-0 font-mono text-xs leading-4 break-words whitespace-pre-wrap opacity-90">
74-
{content}
75-
</pre>
76-
)}
77-
{message.imageParts && message.imageParts.length > 0 && (
78-
<div className="mt-2 flex flex-wrap gap-2">
79-
{message.imageParts.map((img, idx) => (
80-
<img
81-
key={idx}
82-
src={img.url}
83-
alt={`Attachment ${idx + 1}`}
84-
className="border-border-light max-h-[300px] max-w-80 rounded border"
85-
/>
86-
))}
87-
</div>
88-
)}
89-
</MessageWindow>
90-
</>
65+
<MessageWindow
66+
label={queuedLabel}
67+
variant="user"
68+
message={message}
69+
className={className}
70+
buttons={buttons}
71+
>
72+
<UserMessageContent
73+
content={content}
74+
reviews={message.reviews}
75+
imageParts={message.imageParts}
76+
variant="queued"
77+
/>
78+
</MessageWindow>
9179
);
9280
};

src/browser/components/Messages/UserMessage.tsx

Lines changed: 8 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,15 @@
11
import React from "react";
2-
import type { DisplayedMessage, ReviewNoteDataForDisplay } from "@/common/types/message";
2+
import type { DisplayedMessage } from "@/common/types/message";
33
import type { ButtonConfig } from "./MessageWindow";
44
import { MessageWindow } from "./MessageWindow";
5+
import { UserMessageContent } from "./UserMessageContent";
56
import { TerminalOutput } from "./TerminalOutput";
67
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
78
import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard";
89
import { copyToClipboard } from "@/browser/utils/clipboard";
910
import { usePersistedState } from "@/browser/hooks/usePersistedState";
1011
import { VIM_ENABLED_KEY } from "@/common/constants/storage";
1112
import { Clipboard, ClipboardCheck, Pencil } from "lucide-react";
12-
import { ReviewBlockFromData } from "../shared/ReviewBlock";
13-
14-
/** Helper component to render reviews from structured data with optional text */
15-
const ReviewsWithText: React.FC<{
16-
reviews: ReviewNoteDataForDisplay[];
17-
textContent: string;
18-
}> = ({ reviews, textContent }) => (
19-
<div className="space-y-2">
20-
{reviews.map((review, idx) => (
21-
<ReviewBlockFromData key={idx} data={review} />
22-
))}
23-
{textContent && (
24-
<pre className="font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]">
25-
{textContent}
26-
</pre>
27-
)}
28-
</div>
29-
);
3013

3114
interface UserMessageProps {
3215
message: DisplayedMessage & { type: "user" };
@@ -107,15 +90,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
10790
);
10891
}
10992

110-
// Check if we have structured review data in metadata
111-
const hasReviews = message.reviews && message.reviews.length > 0;
112-
113-
// Extract plain text content (without review tags) for display alongside review blocks
114-
const plainTextContent = hasReviews
115-
? content.replace(/<review>[\s\S]*?<\/review>\s*/g, "").trim()
116-
: content;
117-
118-
// Otherwise, render as normal user message
11993
return (
12094
<MessageWindow
12195
label={null}
@@ -124,29 +98,12 @@ export const UserMessage: React.FC<UserMessageProps> = ({
12498
className={className}
12599
variant="user"
126100
>
127-
{hasReviews ? (
128-
// Use structured review data from metadata
129-
<ReviewsWithText reviews={message.reviews!} textContent={plainTextContent} />
130-
) : (
131-
// No reviews - just plain text
132-
content && (
133-
<pre className="font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]">
134-
{content}
135-
</pre>
136-
)
137-
)}
138-
{message.imageParts && message.imageParts.length > 0 && (
139-
<div className="mt-3 flex flex-wrap gap-3">
140-
{message.imageParts.map((img, idx) => (
141-
<img
142-
key={idx}
143-
src={img.url}
144-
alt={`Attachment ${idx + 1}`}
145-
className="max-h-[300px] max-w-72 rounded-xl border border-[var(--color-attachment-border)] object-cover"
146-
/>
147-
))}
148-
</div>
149-
)}
101+
<UserMessageContent
102+
content={content}
103+
reviews={message.reviews}
104+
imageParts={message.imageParts}
105+
variant="sent"
106+
/>
150107
</MessageWindow>
151108
);
152109
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from "react";
2+
import type { ReviewNoteDataForDisplay } from "@/common/types/message";
3+
import type { ImagePart } from "@/common/orpc/schemas";
4+
import { ReviewBlockFromData } from "../shared/ReviewBlock";
5+
6+
interface UserMessageContentProps {
7+
content: string;
8+
reviews?: ReviewNoteDataForDisplay[];
9+
imageParts?: ImagePart[];
10+
/** Controls styling: "sent" for full styling, "queued" for muted preview */
11+
variant: "sent" | "queued";
12+
}
13+
14+
const textStyles = {
15+
sent: "font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]",
16+
queued: "text-subtle m-0 font-mono text-xs leading-4 break-words whitespace-pre-wrap opacity-90",
17+
} as const;
18+
19+
const imageContainerStyles = {
20+
sent: "mt-3 flex flex-wrap gap-3",
21+
queued: "mt-2 flex flex-wrap gap-2",
22+
} as const;
23+
24+
const imageStyles = {
25+
sent: "max-h-[300px] max-w-72 rounded-xl border border-[var(--color-attachment-border)] object-cover",
26+
queued: "border-border-light max-h-[300px] max-w-80 rounded border",
27+
} as const;
28+
29+
/**
30+
* Shared content renderer for user messages (sent and queued).
31+
* Handles reviews, text content, and image attachments.
32+
*/
33+
export const UserMessageContent: React.FC<UserMessageContentProps> = ({
34+
content,
35+
reviews,
36+
imageParts,
37+
variant,
38+
}) => {
39+
const hasReviews = reviews && reviews.length > 0;
40+
41+
// Strip review tags from text when displaying alongside review blocks
42+
const textContent = hasReviews
43+
? content.replace(/<review>[\s\S]*?<\/review>\s*/g, "").trim()
44+
: content;
45+
46+
return (
47+
<>
48+
{hasReviews ? (
49+
<div className="space-y-2">
50+
{reviews.map((review, idx) => (
51+
<ReviewBlockFromData key={idx} data={review} />
52+
))}
53+
{textContent && <pre className={textStyles[variant]}>{textContent}</pre>}
54+
</div>
55+
) : (
56+
content && <pre className={textStyles[variant]}>{content}</pre>
57+
)}
58+
{imageParts && imageParts.length > 0 && (
59+
<div className={imageContainerStyles[variant]}>
60+
{imageParts.map((img, idx) => (
61+
<img
62+
key={idx}
63+
src={img.url}
64+
alt={`Attachment ${idx + 1}`}
65+
className={imageStyles[variant]}
66+
/>
67+
))}
68+
</div>
69+
)}
70+
</>
71+
);
72+
};

src/browser/stores/WorkspaceStore.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,17 @@ export class WorkspaceStore {
281281

282282
// Create QueuedMessage once here instead of on every render
283283
// Use displayText which handles slash commands (shows /compact instead of expanded prompt)
284-
// Show queued message if there's text OR images (support image-only queued messages)
285-
const hasContent = data.queuedMessages.length > 0 || (data.imageParts?.length ?? 0) > 0;
284+
// Show queued message if there's text OR images OR reviews (support review-only queued messages)
285+
const hasContent =
286+
data.queuedMessages.length > 0 ||
287+
(data.imageParts?.length ?? 0) > 0 ||
288+
(data.reviews?.length ?? 0) > 0;
286289
const queuedMessage: QueuedMessage | null = hasContent
287290
? {
288291
id: `queued-${workspaceId}`,
289292
content: data.displayText,
290293
imageParts: data.imageParts,
294+
reviews: data.reviews,
291295
}
292296
: null;
293297

src/browser/stories/App.reviews.stories.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { setupSimpleChatStory, setReviews, createReview } from "./storyHelpers";
77
import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js";
88
import { createUserMessage, createAssistantMessage } from "./mockFactory";
99
import { within, userEvent, waitFor } from "@storybook/test";
10+
import type { WorkspaceChatMessage } from "@/common/orpc/types";
1011

1112
export default {
1213
...appMeta,
@@ -279,3 +280,63 @@ export const BulkReviewActions: AppStory = {
279280
blurActiveElement();
280281
},
281282
};
283+
284+
/**
285+
* Shows reviews in a queued message with nice formatting.
286+
* The queued message appears when the user sends a message while the assistant is busy.
287+
* Reviews are displayed with proper formatting (file path, line range, code snippet, comment).
288+
*/
289+
export const QueuedMessageWithReviews: AppStory = {
290+
render: () => (
291+
<AppWithMocks
292+
setup={() => {
293+
const workspaceId = "ws-queued-reviews";
294+
295+
return setupSimpleChatStory({
296+
workspaceId,
297+
workspaceName: "feature/auth",
298+
projectName: "my-app",
299+
messages: [
300+
createUserMessage("msg-1", "Help me fix authentication", { historySequence: 1 }),
301+
createAssistantMessage("msg-2", "I'll analyze the code and help you fix it...", {
302+
historySequence: 2,
303+
}),
304+
],
305+
onChat: (wsId, emit) => {
306+
// Emit the queued message with reviews (simulating user queued a message with reviews)
307+
emit({
308+
type: "queued-message-changed",
309+
workspaceId: wsId,
310+
queuedMessages: ["Please also check this issue"],
311+
displayText: "Please also check this issue",
312+
reviews: [
313+
{
314+
filePath: "src/api/auth.ts",
315+
lineRange: "42-48",
316+
selectedCode:
317+
"const token = generateToken();\nconst expiry = Date.now() + 3600000;",
318+
userNote: "Consider using a constant for the token expiry duration",
319+
},
320+
{
321+
filePath: "src/utils/helpers.ts",
322+
lineRange: "15",
323+
selectedCode: "function validate(input) { return input.length > 0; }",
324+
userNote: "This validation could be more robust",
325+
},
326+
],
327+
} as WorkspaceChatMessage);
328+
},
329+
});
330+
}}
331+
/>
332+
),
333+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
334+
// Wait for the queued message to appear
335+
const canvas = within(canvasElement);
336+
await waitFor(() => {
337+
canvas.getByText("Queued");
338+
});
339+
await waitForChatInputAutofocusDone(canvasElement);
340+
blurActiveElement();
341+
},
342+
};

src/browser/stories/storyHelpers.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ export interface SimpleChatSetupOptions {
157157
backgroundProcesses?: BackgroundProcessFixture[];
158158
/** Session usage data for Costs tab */
159159
sessionUsage?: MockSessionUsage;
160+
/** Optional custom chat handler for emitting additional events (e.g., queued-message-changed) */
161+
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => void;
160162
}
161163

162164
/**
@@ -191,11 +193,21 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient {
191193
? new Map([[workspaceId, opts.sessionUsage]])
192194
: undefined;
193195

196+
// Create onChat handler that combines static messages with custom handler
197+
const baseOnChat = createOnChatAdapter(chatHandlers);
198+
const onChat = opts.onChat
199+
? (wsId: string, emit: (msg: WorkspaceChatMessage) => void) => {
200+
const cleanup = baseOnChat(wsId, emit);
201+
opts.onChat!(wsId, emit);
202+
return cleanup;
203+
}
204+
: baseOnChat;
205+
194206
// Return ORPC client
195207
return createMockORPCClient({
196208
projects: groupWorkspacesByProject(workspaces),
197209
workspaces,
198-
onChat: createOnChatAdapter(chatHandlers),
210+
onChat,
199211
executeBash: createGitStatusExecutor(gitStatus),
200212
providersConfig: opts.providersConfig,
201213
backgroundProcesses: bgProcesses,

src/common/orpc/schemas/stream.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,24 @@ export const ChatMuxMessageSchema = MuxMessageSchema.extend({
238238
type: z.literal("message"),
239239
});
240240

241+
// Review data schema for queued message display
242+
export const ReviewNoteDataSchema = z.object({
243+
filePath: z.string(),
244+
lineRange: z.string(),
245+
selectedCode: z.string(),
246+
selectedDiff: z.string().optional(),
247+
oldStart: z.number().optional(),
248+
newStart: z.number().optional(),
249+
userNote: z.string(),
250+
});
251+
241252
export const QueuedMessageChangedEventSchema = z.object({
242253
type: z.literal("queued-message-changed"),
243254
workspaceId: z.string(),
244255
queuedMessages: z.array(z.string()),
245256
displayText: z.string(),
246257
imageParts: z.array(ImagePartSchema).optional(),
258+
reviews: z.array(ReviewNoteDataSchema).optional(),
247259
});
248260

249261
export const RestoreToInputEventSchema = z.object({

src/common/types/message.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ export interface QueuedMessage {
253253
id: string;
254254
content: string;
255255
imageParts?: ImagePart[];
256+
/** Structured review data for rich UI display (from muxMetadata) */
257+
reviews?: ReviewNoteDataForDisplay[];
256258
}
257259

258260
// Helper to create a simple text message

src/node/services/agentSession.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,7 @@ export class AgentSession {
766766
queuedMessages: this.messageQueue.getMessages(),
767767
displayText: this.messageQueue.getDisplayText(),
768768
imageParts: this.messageQueue.getImageParts(),
769+
reviews: this.messageQueue.getReviews(),
769770
});
770771
}
771772

0 commit comments

Comments
 (0)