Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 15 additions & 27 deletions src/browser/components/Messages/QueuedMessage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -61,32 +62,19 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = ({
);

return (
<>
<MessageWindow
label={queuedLabel}
variant="user"
message={message}
className={className}
buttons={buttons}
>
{content && (
<pre className="text-subtle m-0 font-mono text-xs leading-4 break-words whitespace-pre-wrap opacity-90">
{content}
</pre>
)}
{message.imageParts && message.imageParts.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{message.imageParts.map((img, idx) => (
<img
key={idx}
src={img.url}
alt={`Attachment ${idx + 1}`}
className="border-border-light max-h-[300px] max-w-80 rounded border"
/>
))}
</div>
)}
</MessageWindow>
</>
<MessageWindow
label={queuedLabel}
variant="user"
message={message}
className={className}
buttons={buttons}
>
<UserMessageContent
content={content}
reviews={message.reviews}
imageParts={message.imageParts}
variant="queued"
/>
</MessageWindow>
);
};
59 changes: 8 additions & 51 deletions src/browser/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,15 @@
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";
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 }) => (
<div className="space-y-2">
{reviews.map((review, idx) => (
<ReviewBlockFromData key={idx} data={review} />
))}
{textContent && (
<pre className="font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]">
{textContent}
</pre>
)}
</div>
);

interface UserMessageProps {
message: DisplayedMessage & { type: "user" };
Expand Down Expand Up @@ -107,15 +90,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
);
}

// 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(/<review>[\s\S]*?<\/review>\s*/g, "").trim()
: content;

// Otherwise, render as normal user message
return (
<MessageWindow
label={null}
Expand All @@ -124,29 +98,12 @@ export const UserMessage: React.FC<UserMessageProps> = ({
className={className}
variant="user"
>
{hasReviews ? (
// Use structured review data from metadata
<ReviewsWithText reviews={message.reviews!} textContent={plainTextContent} />
) : (
// No reviews - just plain text
content && (
<pre className="font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]">
{content}
</pre>
)
)}
{message.imageParts && message.imageParts.length > 0 && (
<div className="mt-3 flex flex-wrap gap-3">
{message.imageParts.map((img, idx) => (
<img
key={idx}
src={img.url}
alt={`Attachment ${idx + 1}`}
className="max-h-[300px] max-w-72 rounded-xl border border-[var(--color-attachment-border)] object-cover"
/>
))}
</div>
)}
<UserMessageContent
content={content}
reviews={message.reviews}
imageParts={message.imageParts}
variant="sent"
/>
</MessageWindow>
);
};
72 changes: 72 additions & 0 deletions src/browser/components/Messages/UserMessageContent.tsx
Original file line number Diff line number Diff line change
@@ -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<UserMessageContentProps> = ({
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(/<review>[\s\S]*?<\/review>\s*/g, "").trim()
: content;

return (
<>
{hasReviews ? (
<div className="space-y-2">
{reviews.map((review, idx) => (
<ReviewBlockFromData key={idx} data={review} />
))}
{textContent && <pre className={textStyles[variant]}>{textContent}</pre>}
</div>
) : (
content && <pre className={textStyles[variant]}>{content}</pre>
)}
{imageParts && imageParts.length > 0 && (
<div className={imageContainerStyles[variant]}>
{imageParts.map((img, idx) => (
<img
key={idx}
src={img.url}
alt={`Attachment ${idx + 1}`}
className={imageStyles[variant]}
/>
))}
</div>
)}
</>
);
};
8 changes: 6 additions & 2 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
61 changes: 61 additions & 0 deletions src/browser/stories/App.reviews.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: () => (
<AppWithMocks
setup={() => {
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();
},
};
14 changes: 13 additions & 1 deletion src/browser/stories/storyHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ export class AgentSession {
queuedMessages: this.messageQueue.getMessages(),
displayText: this.messageQueue.getDisplayText(),
imageParts: this.messageQueue.getImageParts(),
reviews: this.messageQueue.getReviews(),
});
}

Expand Down
Loading