From c89e187864ce7d2e7938f9a1817d8ef5145ed60b Mon Sep 17 00:00:00 2001 From: mask Date: Thu, 12 Mar 2026 20:20:29 -0500 Subject: [PATCH 1/6] Add draft diff context comments for turn diffs --- apps/web/src/components/ChatView.tsx | 97 ++-- .../components/DiffContextCommentDraft.tsx | 100 +++++ .../src/components/DiffPanel.logic.test.ts | 40 ++ apps/web/src/components/DiffPanel.logic.ts | 423 ++++++++++++++++++ apps/web/src/components/DiffPanel.tsx | 155 ++++++- .../chat/ComposerPendingDiffComments.tsx | 40 ++ .../chat/DiffContextCommentsAttachment.tsx | 37 ++ .../src/components/chat/MessagesTimeline.tsx | 30 +- apps/web/src/components/timelineHeight.ts | 15 +- apps/web/src/composerDraftStore.test.ts | 140 +++++- apps/web/src/composerDraftStore.ts | 244 ++++++++++ apps/web/src/lib/diffContextComments.test.ts | 88 ++++ apps/web/src/lib/diffContextComments.ts | 182 ++++++++ 13 files changed, 1540 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/components/DiffContextCommentDraft.tsx create mode 100644 apps/web/src/components/DiffPanel.logic.test.ts create mode 100644 apps/web/src/components/DiffPanel.logic.ts create mode 100644 apps/web/src/components/chat/ComposerPendingDiffComments.tsx create mode 100644 apps/web/src/components/chat/DiffContextCommentsAttachment.tsx create mode 100644 apps/web/src/lib/diffContextComments.test.ts create mode 100644 apps/web/src/lib/diffContextComments.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 64c15ba2f..5fb926ed7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -124,6 +124,7 @@ import { type ComposerImageAttachment, type DraftThreadEnvMode, type PersistedComposerImageAttachment, + flushComposerDraftStorage, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; @@ -141,6 +142,7 @@ import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; +import { ComposerPendingDiffComments } from "./chat/ComposerPendingDiffComments"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; @@ -159,6 +161,7 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { appendDiffContextCommentsToPrompt } from "../lib/diffContextComments"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -218,6 +221,12 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.syncPersistedAttachments, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const restoreComposerDraftSendContent = useComposerDraftStore( + (store) => store.restoreComposerSendContent, + ); + const clearComposerDraftDiffContextComments = useComposerDraftStore( + (store) => store.clearDiffContextComments, + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, @@ -361,6 +370,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; + const pendingDiffContextComments = composerDraft.diffContextComments; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = @@ -558,6 +568,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const hasPendingDiffContextComments = pendingDiffContextComments.length > 0; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -2219,9 +2230,15 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger(null); return; } - if (!trimmed && composerImages.length === 0) return; + if (!trimmed && composerImages.length === 0 && !hasPendingDiffContextComments) return; if (!activeProject) return; const threadIdForSend = activeThread.id; + const pendingDiffContextCommentsSnapshot = [...pendingDiffContextComments]; + const persistedComposerAttachmentsSnapshot = [...composerDraft.persistedAttachments]; + const messageTextForSend = appendDiffContextCommentsToPrompt( + trimmed, + pendingDiffContextComments, + ); const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath @@ -2268,7 +2285,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: messageTextForSend, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2281,6 +2298,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(threadIdForSend, null); promptRef.current = ""; clearComposerDraftContent(threadIdForSend); + flushComposerDraftStorage(); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2406,7 +2424,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2434,7 +2452,9 @@ export default function ChatView({ threadId }: ChatViewProps) { if ( !turnStartSucceeded && promptRef.current.length === 0 && - composerImagesRef.current.length === 0 + composerImagesRef.current.length === 0 && + (useComposerDraftStore.getState().draftsByThreadId[threadIdForSend]?.diffContextComments + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -2445,9 +2465,15 @@ export default function ChatView({ threadId }: ChatViewProps) { return next.length === existing.length ? existing : next; }); promptRef.current = trimmed; + restoreComposerDraftSendContent(threadIdForSend, { + prompt: trimmed, + images: composerImagesSnapshot.map(cloneComposerImageForRetry), + persistedAttachments: persistedComposerAttachmentsSnapshot, + diffContextComments: pendingDiffContextCommentsSnapshot, + }); + flushComposerDraftStorage(); setPrompt(trimmed); setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } setThreadError( @@ -3367,38 +3393,40 @@ export default function ChatView({ threadId }: ChatViewProps) { {!isComposerApprovalState && pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
+ (pendingDiffContextComments.length > 0 || composerImages.length > 0) && ( +
+ clearComposerDraftDiffContextComments(threadId)} + /> {composerImages.map((image) => ( -
{ + const preview = buildExpandedImagePreview(composerImages, image.id); + if (!preview) return; + setExpandedImage(preview); + }} > {image.previewUrl ? ( - + ) : ( -
- {image.name} -
+ + IMG + )} + + {image.name} + {nonPersistedComposerImageIdSet.has(image.id) && ( @@ -3424,13 +3452,16 @@ export default function ChatView({ threadId }: ChatViewProps) { -
+ ))}
)} @@ -3723,7 +3754,9 @@ export default function ChatView({ threadId }: ChatViewProps) { disabled={ isSendBusy || isConnecting || - (!prompt.trim() && composerImages.length === 0) + (!prompt.trim() && + composerImages.length === 0 && + !hasPendingDiffContextComments) } aria-label={ isConnecting diff --git a/apps/web/src/components/DiffContextCommentDraft.tsx b/apps/web/src/components/DiffContextCommentDraft.tsx new file mode 100644 index 000000000..ca19abd57 --- /dev/null +++ b/apps/web/src/components/DiffContextCommentDraft.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; +import { Button } from "./ui/button"; + +interface DiffContextCommentDraftProps { + filePath: string; + lineStart: number; + lineEnd: number; + body: string; + error: string; + onBodyChange: (value: string) => void; + onCancel: () => void; + onSubmit: () => void; + onDelete?: () => void; + submitLabel?: string; +} + +function formatLineRange(start: number, end: number): string { + return start === end ? `${start}` : `${start}-${end}`; +} + +export const DIFF_CONTEXT_COMMENT_CARD_STYLE = { + width: "min(44rem, calc(100cqw - 3.5rem), calc(100vw - 7.5rem))", + maxWidth: "100%", +} as const; + +export function DiffContextCommentDraft({ + filePath, + lineStart, + lineEnd, + body, + error, + onBodyChange, + onCancel, + onSubmit, + onDelete, + submitLabel = "Comment", +}: DiffContextCommentDraftProps) { + const textareaRef = useRef(null); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + + textarea.focus(); + const cursorPosition = textarea.value.length; + textarea.setSelectionRange(cursorPosition, cursorPosition); + }, [filePath, lineStart, lineEnd]); + + return ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > +
+
+