Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dc6ca67
πŸ€– feat: add stateful pending reviews
ammar-agent Dec 8, 2025
6009525
refactor: store ReviewNoteData instead of parsing formatted strings
ammar-agent Dec 8, 2025
876f19d
fix: add error boundary to PendingReviewsBanner for corrupted data
ammar-agent Dec 8, 2025
f1e1b4b
feat: enhance pending reviews UI with full features
ammar-agent Dec 8, 2025
d508eb5
πŸ€– feat: improve pending reviews UX with attachment model
ammar-agent Dec 8, 2025
b25ea62
πŸ€– fix: improve pending reviews UX based on feedback
ammar-agent Dec 8, 2025
ca80d5a
fix: pending reviews reactivity, button visibility, and styling
ammar-agent Dec 8, 2025
aa97e74
fix: review UX improvements - X button inside borders, allow review-o…
ammar-agent Dec 8, 2025
1f00d9c
fix: review UI polish - code elision, rounding, and text extraction
ammar-agent Dec 8, 2025
af67d42
fix: review UX improvements - spacing, editing, growth
ammar-agent Dec 8, 2025
59d73f9
feat: persist review data in muxMetadata for rich UI display
ammar-agent Dec 8, 2025
4391247
refactor: DRY up review components and use shared keybinds
ammar-agent Dec 8, 2025
c04ed8f
refactor: use review status for attached state instead of separate st…
ammar-agent Dec 8, 2025
a4ec617
refactor: make reviews orthogonal to message metadata type
ammar-agent Dec 8, 2025
0a6d961
refactor: rename pendingReviews storage key to reviews
ammar-agent Dec 8, 2025
855383b
feat: show comment preview in collapsed review items
ammar-agent Dec 8, 2025
e1beb02
refactor: rename Pending* types and files to remove 'pending' prefix
ammar-agent Dec 8, 2025
1c8593d
fix: hide reviews from ChatInput during send to avoid duplicate display
ammar-agent Dec 8, 2025
edbac7c
fix: make reviews orthogonal to auto-compaction
ammar-agent Dec 8, 2025
0dcf3fc
refactor: consolidate ReviewNoteData types and formatReviewForModel
ammar-agent Dec 8, 2025
9d704a9
refactor: simplify reviewsData extraction using r.data directly
ammar-agent Dec 8, 2025
94d5971
fix: align ReviewsBanner content with chat on wide viewports
ammar-agent Dec 8, 2025
2a90ce5
fix: add horizontal padding to ReviewsBanner hover state
ammar-agent Dec 8, 2025
7b8000d
fix: use negative margin to align ReviewsBanner content with chat
ammar-agent Dec 8, 2025
2955610
fix: revert negative margin, keep padding for hover state
ammar-agent Dec 8, 2025
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
24 changes: 20 additions & 4 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import { useForceCompaction } from "@/browser/hooks/useForceCompaction";
import { useAPI } from "@/browser/contexts/API";
import { useReviews } from "@/browser/hooks/useReviews";
import { ReviewsBanner } from "./ReviewsBanner";
import type { ReviewNoteData } from "@/common/types/review";

interface AIViewProps {
workspaceId: string;
Expand Down Expand Up @@ -105,6 +108,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
const workspaceState = useWorkspaceState(workspaceId);
const aggregator = useWorkspaceAggregator(workspaceId);
const workspaceUsage = useWorkspaceUsage(workspaceId);

// Reviews state
const reviews = useReviews(workspaceId);
const { options } = useProviderOptions();
const use1M = options.anthropic?.use1MContext ?? false;
// Get pending model for auto-compaction settings (threshold is per-model)
Expand Down Expand Up @@ -213,10 +219,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
chatInputAPI.current = api;
}, []);

// Handler for review notes from Code Review tab
const handleReviewNote = useCallback((note: string) => {
chatInputAPI.current?.appendText(note);
}, []);
// Handler for review notes from Code Review tab - adds review (starts attached)
const handleReviewNote = useCallback(
(data: ReviewNoteData) => {
reviews.addReview(data);
// New reviews start with status "attached" so they appear in chat input immediately
},
[reviews]
);

// Handler for manual compaction from CompactionWarning click
const handleCompactClick = useCallback(() => {
Expand Down Expand Up @@ -532,6 +542,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
onEditUserMessage={handleEditUserMessage}
workspaceId={workspaceId}
isCompacting={isCompacting}
onReviewNote={handleReviewNote}
/>
</div>
{isAtCutoff && (
Expand Down Expand Up @@ -606,6 +617,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
onCompactClick={handleCompactClick}
/>
)}
<ReviewsBanner workspaceId={workspaceId} />
<ChatInput
variant="workspace"
workspaceId={workspaceId}
Expand All @@ -621,6 +633,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
canInterrupt={canInterrupt}
onReady={handleChatInputReady}
autoCompactionCheck={autoCompactionResult}
attachedReviews={reviews.attachedReviews}
onDetachReview={reviews.detachReview}
onCheckReviews={(ids) => ids.forEach((id) => reviews.checkReview(id))}
onUpdateReviewNote={reviews.updateReviewNote}
/>
</div>

Expand Down
94 changes: 77 additions & 17 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {

import type { ThinkingLevel } from "@/common/types/thinking";
import type { MuxFrontendMetadata } from "@/common/types/message";
import { prepareUserMessageForSend } from "@/common/types/message";
import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels";
import { useTelemetry } from "@/browser/hooks/useTelemetry";

Expand All @@ -75,6 +76,7 @@ import { useTutorial } from "@/browser/contexts/TutorialContext";
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
import { VoiceInputButton } from "./VoiceInputButton";
import { RecordingOverlay } from "./RecordingOverlay";
import { ReviewBlockFromData } from "../shared/ReviewBlock";

type TokenCountReader = () => number;

Expand Down Expand Up @@ -140,19 +142,22 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {

const [input, setInput] = usePersistedState(storageKeys.inputKey, "", { listener: true });
const [isSending, setIsSending] = useState(false);
const [hideReviewsDuringSend, setHideReviewsDuringSend] = useState(false);
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);
const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
const [providerNames, setProviderNames] = useState<string[]>([]);
const [toast, setToast] = useState<Toast | null>(null);
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
// Attached reviews come from parent via props (persisted in pendingReviews state)
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
const handleToastDismiss = useCallback(() => {
setToast(null);
}, []);
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(null);

// Draft state combines text input and image attachments
// Use these helpers to avoid accidentally losing images when modifying text
// Reviews are managed separately via props (persisted in pendingReviews state)
interface DraftState {
text: string;
images: ImageAttachment[];
Expand Down Expand Up @@ -236,7 +241,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
);
const hasTypedText = input.trim().length > 0;
const hasImages = imageAttachments.length > 0;
const canSend = (hasTypedText || hasImages) && !disabled && !isSending;
const hasReviews = attachedReviews.length > 0;
const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSending;
// Setter for model - updates localStorage directly so useSendMessageOptions picks it up
const setPreferredModel = useCallback(
(model: string) => {
Expand Down Expand Up @@ -946,8 +952,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}
setIsSending(true);

// Save current state for restoration on error
const previousImageAttachments = [...imageAttachments];
// Save current draft state for restoration on error
const preSendDraft = getDraft();

// Auto-compaction check (workspace variant only)
// Check if we should auto-compact before sending this message
Expand All @@ -962,9 +968,18 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
mediaType: img.mediaType,
}));

// Prepare reviews data for the continue message (orthogonal to compaction)
// Review.data is already ReviewNoteData shape
const reviewsData =
attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined;

// Capture review IDs for marking as checked on success
const sentReviewIds = attachedReviews.map((r) => r.id);

// Clear input immediately for responsive UX
setInput("");
setImageAttachments([]);
setHideReviewsDuringSend(true);

try {
const result = await executeCompaction({
Expand All @@ -974,21 +989,25 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
text: messageText,
imageParts,
model: sendMessageOptions.model,
reviews: reviewsData,
},
sendMessageOptions,
});

if (!result.success) {
// Restore on error
setInput(messageText);
setImageAttachments(previousImageAttachments);
setDraft(preSendDraft);
setToast({
id: Date.now().toString(),
type: "error",
title: "Auto-Compaction Failed",
message: result.error ?? "Failed to start auto-compaction",
});
} else {
// Mark reviews as checked on success
if (sentReviewIds.length > 0) {
props.onCheckReviews?.(sentReviewIds);
}
setToast({
id: Date.now().toString(),
type: "success",
Expand All @@ -998,8 +1017,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}
} catch (error) {
// Restore on unexpected error
setInput(messageText);
setImageAttachments(previousImageAttachments);
setDraft(preSendDraft);
setToast({
id: Date.now().toString(),
type: "error",
Expand All @@ -1009,6 +1027,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
});
} finally {
setIsSending(false);
setHideReviewsDuringSend(false);
}

return; // Skip normal send
Expand Down Expand Up @@ -1072,18 +1091,33 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}
}

// Clear input and images immediately for responsive UI
// These will be restored if the send operation fails
// Process reviews into message text and metadata using shared utility
// Review.data is already ReviewNoteData shape
const reviewsData =
attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined;
const { finalText: finalMessageText, metadata: reviewMetadata } = prepareUserMessageForSend(
{ text: actualMessageText, reviews: reviewsData },
muxMetadata
);
muxMetadata = reviewMetadata;

// Capture review IDs before clearing (for marking as checked on success)
const sentReviewIds = attachedReviews.map((r) => r.id);

// Clear input, images, and hide reviews immediately for responsive UI
// Text/images are restored if send fails; reviews remain "attached" in state
// so they'll reappear naturally on failure (we only call onCheckReviews on success)
setInput("");
setImageAttachments([]);
setHideReviewsDuringSend(true);
// Clear inline height style - VimTextArea's useLayoutEffect will handle sizing
if (inputRef.current) {
inputRef.current.style.height = "";
}

const result = await api.workspace.sendMessage({
workspaceId: props.workspaceId,
message: actualMessageText,
message: finalMessageText,
options: {
...sendMessageOptions,
...compactionOptions,
Expand All @@ -1098,20 +1132,24 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
console.error("Failed to send message:", result.error);
// Show error using enhanced toast
setToast(createErrorToast(result.error));
// Restore input and images on error so user can try again
setInput(messageText);
setImageAttachments(previousImageAttachments);
// Restore draft on error so user can try again
setDraft(preSendDraft);
} else {
// Track telemetry for successful message send
telemetry.messageSent(
props.workspaceId,
sendMessageOptions.model,
mode,
actualMessageText.length,
finalMessageText.length,
runtimeType,
sendMessageOptions.thinkingLevel ?? "off"
);

// Mark attached reviews as completed (checked)
if (sentReviewIds.length > 0) {
props.onCheckReviews?.(sentReviewIds);
}

// Exit editing mode if we were editing
if (editingMessage && props.onCancelEdit) {
props.onCancelEdit();
Expand All @@ -1127,10 +1165,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
raw: error instanceof Error ? error.message : "Failed to send message",
})
);
setInput(messageText);
setImageAttachments(previousImageAttachments);
// Restore draft on error
setDraft(preSendDraft);
} finally {
setIsSending(false);
setHideReviewsDuringSend(false);
}
} finally {
// Always restore focus at the end
Expand Down Expand Up @@ -1308,6 +1347,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
<ChatInputToast toast={toast} onDismiss={handleToastDismiss} />
)}

{/* Attached reviews preview - show styled blocks with remove/edit buttons */}
{/* Hide during send to avoid duplicate display with the sent message */}
{variant === "workspace" && attachedReviews.length > 0 && !hideReviewsDuringSend && (
<div className="border-border max-h-[50vh] space-y-2 overflow-y-auto border-b px-1.5 py-1.5">
{attachedReviews.map((review) => (
<ReviewBlockFromData
key={review.id}
data={review.data}
onRemove={
props.onDetachReview ? () => props.onDetachReview!(review.id) : undefined
}
onEditComment={
props.onUpdateReviewNote
? (newNote) => props.onUpdateReviewNote!(review.id, newNote)
: undefined
}
/>
))}
</div>
)}

{/* Command suggestions - workspace only */}
{variant === "workspace" && (
<CommandSuggestions
Expand Down
9 changes: 9 additions & 0 deletions src/browser/components/ChatInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ImagePart } from "@/common/orpc/types";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { TelemetryRuntimeType } from "@/common/telemetry/payload";
import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck";
import type { Review } from "@/common/types/review";

export interface ChatInputAPI {
focus: () => void;
Expand Down Expand Up @@ -29,6 +30,14 @@ export interface ChatInputWorkspaceVariant {
disabled?: boolean;
onReady?: (api: ChatInputAPI) => void;
autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation
/** Reviews currently attached to chat (from useReviews hook) */
attachedReviews?: Review[];
/** Detach a review from chat input (sets status to pending) */
onDetachReview?: (reviewId: string) => void;
/** Mark reviews as checked after sending */
onCheckReviews?: (reviewIds: string[]) => void;
/** Update a review's comment/note */
onUpdateReviewNote?: (reviewId: string, newNote: string) => void;
}

// Creation variant: simplified for first message / workspace creation
Expand Down
14 changes: 12 additions & 2 deletions src/browser/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import type { DisplayedMessage } from "@/common/types/message";
import type { ReviewNoteData } from "@/common/types/review";
import { UserMessage } from "./UserMessage";
import { AssistantMessage } from "./AssistantMessage";
import { ToolMessage } from "./ToolMessage";
Expand All @@ -15,11 +16,13 @@ interface MessageRendererProps {
onEditQueuedMessage?: () => void;
workspaceId?: string;
isCompacting?: boolean;
/** Handler for adding review notes from inline diffs */
onReviewNote?: (data: ReviewNoteData) => void;
}

// Memoized to prevent unnecessary re-renders when parent (AIView) updates
export const MessageRenderer = React.memo<MessageRendererProps>(
({ message, className, onEditUserMessage, workspaceId, isCompacting }) => {
({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => {
// Route based on message type
switch (message.type) {
case "user":
Expand All @@ -41,7 +44,14 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
/>
);
case "tool":
return <ToolMessage message={message} className={className} workspaceId={workspaceId} />;
return (
<ToolMessage
message={message}
className={className}
workspaceId={workspaceId}
onReviewNote={onReviewNote}
/>
);
case "reasoning":
return <ReasoningMessage message={message} className={className} />;
case "stream-error":
Expand Down
Loading
Loading