diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 64c15ba2f..2906de2da 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`; @@ -174,6 +177,17 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function extendReplacementRangeForTrailingSpace( + text: string, + rangeEnd: number, + replacement: string, +): number { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +} + interface ChatViewProps { threadId: ThreadId; } @@ -218,6 +232,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 +381,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 +579,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 +2241,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 +2296,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 +2309,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(threadIdForSend, null); promptRef.current = ""; clearComposerDraftContent(threadIdForSend); + flushComposerDraftStorage(); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2406,7 +2435,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 +2463,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 +2476,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( @@ -2968,17 +3005,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [composerCursor]); - const extendReplacementRangeForTrailingSpace = ( - text: string, - rangeEnd: number, - replacement: string, - ): number => { - if (!replacement.endsWith(" ")) { - return rangeEnd; - } - return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; - }; - const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; @@ -3367,38 +3393,40 @@ export default function ChatView({ threadId }: ChatViewProps) { {!isComposerApprovalState && pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {row.message.text}
+ {visibleUserText}
)}
{formatTimestamp(row.message.createdAt, timestampFormat)}
diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 78a5f6539..94925b99b 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,3 +1,5 @@ +import { extractTrailingDiffContextComments } from "../lib/diffContextComments"; + const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; const LINE_HEIGHT_PX = 22; @@ -6,6 +8,7 @@ const USER_BASE_HEIGHT_PX = 96; const ATTACHMENTS_PER_ROW = 2; // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +const USER_DIFF_CONTEXT_COMMENTS_HEIGHT_PX = 40; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; @@ -74,12 +77,20 @@ export function estimateTimelineMessageHeight( } if (message.role === "user") { + const extractedDiffComments = extractTrailingDiffContextComments(message.text); const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const estimatedLines = + extractedDiffComments.promptText.length > 0 + ? estimateWrappedLineCount(extractedDiffComments.promptText, charsPerLine) + : 0; const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; + const diffCommentsHeight = + extractedDiffComments.commentCount > 0 ? USER_DIFF_CONTEXT_COMMENTS_HEIGHT_PX : 0; + return ( + USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight + diffCommentsHeight + ); } // `system` messages are not rendered in the chat timeline, but keep a stable diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..a63eeadee 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -6,6 +6,7 @@ import { createDebouncedStorage, useComposerDraftStore, } from "./composerDraftStore"; +import { type DiffContextCommentDraft } from "./lib/diffContextComments"; function makeImage(input: { id: string; @@ -34,6 +35,30 @@ function makeImage(input: { }; } +function makeDiffComment(input: { + id: string; + threadId?: ThreadId; + filePath?: string; + lineStart?: number; + lineEnd?: number; + side?: "additions" | "deletions"; + body?: string; + turnId?: string | null; + createdAt?: string; +}): DiffContextCommentDraft { + return { + id: input.id, + threadId: input.threadId ?? ThreadId.makeUnsafe("thread-comments"), + turnId: input.turnId ? TurnId.makeUnsafe(input.turnId) : null, + filePath: input.filePath ?? "src/example.ts", + lineStart: input.lineStart ?? 12, + lineEnd: input.lineEnd ?? 12, + side: input.side ?? "additions", + body: input.body ?? "Tighten this guard.", + createdAt: input.createdAt ?? "2026-03-12T00:00:00.000Z", + }; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; @@ -156,6 +181,120 @@ describe("composerDraftStore clearComposerContent", () => { expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); }); + + it("clears pending diff context comments with the rest of the composer draft", () => { + useComposerDraftStore + .getState() + .addDiffContextComment(threadId, makeDiffComment({ id: "comment-clear", threadId })); + + useComposerDraftStore.getState().clearComposerContent(threadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); + + it("preserves pending diff context comments when clearing only sendable composer content", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadId, "Retry me"); + store.addImage( + threadId, + makeImage({ + id: "img-send-only", + previewUrl: "blob:send-only", + }), + ); + store.addDiffContextComment( + threadId, + makeDiffComment({ id: "comment-send-only", threadId, body: "Keep this draft comment." }), + ); + + store.clearComposerSendContent(threadId); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.prompt).toBe(""); + expect(draft?.images).toEqual([]); + expect(draft?.diffContextComments.map((comment) => comment.id)).toEqual(["comment-send-only"]); + }); + + it("restores sendable composer content after a failed send", () => { + const store = useComposerDraftStore.getState(); + const image = makeImage({ + id: "img-restore", + previewUrl: "blob:restore", + }); + const comment = makeDiffComment({ + id: "comment-restore", + threadId, + body: "Restore this diff note.", + }); + + store.clearComposerContent(threadId); + store.restoreComposerSendContent(threadId, { + prompt: "Try again", + images: [image], + persistedAttachments: [], + diffContextComments: [comment], + }); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.prompt).toBe("Try again"); + expect(draft?.images.map((entry) => entry.id)).toEqual(["img-restore"]); + expect(draft?.diffContextComments.map((entry) => entry.id)).toEqual(["comment-restore"]); + }); +}); + +describe("composerDraftStore diff context comments", () => { + const threadId = ThreadId.makeUnsafe("thread-comments"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("adds, updates, removes, and clears pending diff comments", () => { + const store = useComposerDraftStore.getState(); + + store.addDiffContextComment(threadId, makeDiffComment({ id: "comment-1", threadId })); + store.addDiffContextComment( + threadId, + makeDiffComment({ + id: "comment-2", + threadId, + body: "Keep the fallback.", + side: "deletions", + }), + ); + + expect( + useComposerDraftStore + .getState() + .draftsByThreadId[threadId]?.diffContextComments.map((comment) => comment.id), + ).toEqual(["comment-1", "comment-2"]); + + store.updateDiffContextComment(threadId, "comment-1", { + body: "Use the shared helper instead.", + }); + + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.diffContextComments[0]?.body, + ).toBe("Use the shared helper instead."); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.diffContextComments[0]?.filePath, + ).toBe("src/example.ts"); + + store.removeDiffContextComment(threadId, "comment-2"); + expect( + useComposerDraftStore + .getState() + .draftsByThreadId[threadId]?.diffContextComments.map((comment) => comment.id), + ).toEqual(["comment-1"]); + + store.clearDiffContextComments(threadId); + store.clearDiffContextComments(threadId); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); }); describe("composerDraftStore project draft thread mapping", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..d41135b48 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3,6 +3,7 @@ import { ProjectId, REASONING_EFFORT_OPTIONS_BY_PROVIDER, ThreadId, + TurnId, type CodexReasoningEffort, type ProviderKind, type ProviderInteractionMode, @@ -10,6 +11,10 @@ import { } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import { + type DiffContextCommentDraft, + type DiffContextCommentDraftUpdate, +} from "./lib/diffContextComments"; import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; @@ -51,6 +56,10 @@ const composerDebouncedStorage: DebouncedStorage = ? createDebouncedStorage(localStorage) : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; +export function flushComposerDraftStorage(): void { + composerDebouncedStorage.flush(); +} + // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { @@ -74,6 +83,7 @@ export interface ComposerImageAttachment extends Omit