diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ba0a0eddf30..319e3d89c3a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; +import { INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER } from "../lib/diffContextComments"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -65,6 +66,17 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); + + it("strips diff comment placeholders from visible prompt text", () => { + const state = deriveComposerSendState({ + prompt: INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, + imageCount: 0, + terminalContexts: [], + }); + + expect(state.trimmedPrompt).toBe(""); + expect(state.hasSendableContent).toBe(false); + }); }); describe("buildExpiredTerminalContextToastCopy", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b77cc7b1762..b3f3248de5b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -16,6 +16,7 @@ import { stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +import { stripInlineDiffContextCommentPlaceholders } from "../lib/diffContextComments"; import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; @@ -189,7 +190,9 @@ export function deriveComposerSendState(options: { expiredTerminalContextCount: number; hasSendableContent: boolean; } { - const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); + const trimmedPrompt = stripInlineDiffContextCommentPlaceholders( + stripInlineTerminalContextPlaceholders(options.prompt), + ).trim(); const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c76059b6a8..5a58c466aba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -123,6 +123,7 @@ import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, + flushComposerDraftStorage, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; @@ -132,6 +133,7 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; +import { appendDiffContextCommentsToPrompt } from "../lib/diffContextComments"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; @@ -631,17 +633,15 @@ export default function ChatView(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); - const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const restoreComposerDraftSendContent = useComposerDraftStore( + (store) => store.restoreComposerSendContent, + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, @@ -2379,7 +2379,9 @@ export default function ChatView(props: ChatViewProps) { if (!sendCtx) return; const { images: composerImages, + persistedAttachments: composerPersistedAttachments, terminalContexts: composerTerminalContexts, + diffContextComments: composerDiffContextComments, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -2412,7 +2414,9 @@ export default function ChatView(props: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerDiffContextComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -2422,7 +2426,8 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.resetCursorState(); return; } - if (!hasSendableContent) { + const hasPendingDiffContextComments = composerDiffContextComments.length > 0; + if (!hasSendableContent && !hasPendingDiffContextComments) { if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, @@ -2459,11 +2464,17 @@ export default function ChatView(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; + const composerPersistedAttachmentsSnapshot = [...composerPersistedAttachments]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const messageTextForSend = appendTerminalContextsToPrompt( + const composerDiffContextCommentsSnapshot = [...composerDiffContextComments]; + const messageTextWithTerminalContexts = appendTerminalContextsToPrompt( promptForSend, composerTerminalContextsSnapshot, ); + const messageTextForSend = appendDiffContextCommentsToPrompt( + messageTextWithTerminalContexts, + composerDiffContextCommentsSnapshot, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -2526,6 +2537,7 @@ export default function ChatView(props: ChatViewProps) { } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); + flushComposerDraftStorage(); composerRef.current?.resetCursorState(); let turnStartSucceeded = false; @@ -2630,7 +2642,9 @@ export default function ChatView(props: ChatViewProps) { !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 + composerTerminalContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.diffContextComments + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -2644,9 +2658,14 @@ export default function ChatView(props: ChatViewProps) { const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, promptForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); + restoreComposerDraftSendContent(composerDraftTarget, { + prompt: promptForSend, + images: retryComposerImages, + persistedAttachments: composerPersistedAttachmentsSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + diffContextComments: composerDiffContextCommentsSnapshot, + }); + flushComposerDraftStorage(); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 8113099638e..98cdc149ae5 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -65,6 +65,10 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "~/lib/terminalContext"; +import { + INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, + type DiffContextCommentDraft, +} from "~/lib/diffContextComments"; import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; import { @@ -74,6 +78,7 @@ import { COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { ComposerDiffContextCommentInlineChip } from "./chat/DiffContextCommentInlineChip"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -123,10 +128,21 @@ type SerializedComposerTerminalContextNode = Spread< SerializedLexicalNode >; -const ComposerTerminalContextActionsContext = createContext<{ +type SerializedComposerDiffContextCommentNode = Spread< + { + comment: DiffContextCommentDraft; + type: "composer-diff-context-comment"; + version: 1; + }, + SerializedLexicalNode +>; + +const ComposerInlineTokenActionsContext = createContext<{ onRemoveTerminalContext: (contextId: string) => void; + onRemoveDiffContextComment: (commentId: string) => void; }>({ onRemoveTerminalContext: () => {}, + onRemoveDiffContextComment: () => {}, }); function ComposerMentionDecorator(props: { path: string }) { @@ -432,16 +448,82 @@ function $createComposerTerminalContextNode( return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } +function ComposerDiffContextCommentDecorator(props: { comment: DiffContextCommentDraft }) { + return ; +} + +class ComposerDiffContextCommentNode extends DecoratorNode { + __comment: DiffContextCommentDraft; + + static override getType(): string { + return "composer-diff-context-comment"; + } + + static override clone(node: ComposerDiffContextCommentNode): ComposerDiffContextCommentNode { + return new ComposerDiffContextCommentNode(node.__comment, node.__key); + } + + static override importJSON( + serializedNode: SerializedComposerDiffContextCommentNode, + ): ComposerDiffContextCommentNode { + return $createComposerDiffContextCommentNode(serializedNode.comment); + } + + constructor(comment: DiffContextCommentDraft, key?: NodeKey) { + super(key); + this.__comment = comment; + } + + override exportJSON(): SerializedComposerDiffContextCommentNode { + return { + ...super.exportJSON(), + comment: this.__comment, + type: "composer-diff-context-comment", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = "inline-flex align-middle leading-none"; + return dom; + } + + override updateDOM(): false { + return false; + } + + override getTextContent(): string { + return INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER; + } + + override isInline(): true { + return true; + } + + override decorate(): ReactElement { + return ; + } +} + +function $createComposerDiffContextCommentNode( + comment: DiffContextCommentDraft, +): ComposerDiffContextCommentNode { + return $applyNodeReplacement(new ComposerDiffContextCommentNode(comment)); +} + type ComposerInlineTokenNode = | ComposerMentionNode | ComposerSkillNode - | ComposerTerminalContextNode; + | ComposerTerminalContextNode + | ComposerDiffContextCommentNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( candidate instanceof ComposerMentionNode || candidate instanceof ComposerSkillNode || - candidate instanceof ComposerTerminalContextNode + candidate instanceof ComposerTerminalContextNode || + candidate instanceof ComposerDiffContextCommentNode ); } @@ -466,6 +548,24 @@ function terminalContextSignature(contexts: ReadonlyArray) .join("\u001e"); } +function diffContextCommentSignature(comments: ReadonlyArray): string { + return comments + .map((comment) => + [ + comment.id, + comment.threadId, + comment.turnId, + comment.filePath, + comment.lineStart, + comment.lineEnd, + comment.side, + comment.createdAt, + comment.body, + ].join("\u001f"), + ) + .join("\u001e"); +} + function skillSignature(skills: ReadonlyArray): string { return skills .map((skill) => @@ -828,6 +928,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, + diffContextComments: ReadonlyArray, skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); @@ -835,7 +936,7 @@ function $setComposerEditorPrompt( const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); + const segments = splitPromptIntoComposerSegments(prompt, terminalContexts, diffContextComments); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); @@ -858,6 +959,12 @@ function $setComposerEditorPrompt( } continue; } + if (segment.type === "diff-context-comment") { + if (segment.comment) { + paragraph.append($createComposerDiffContextCommentNode(segment.comment)); + } + continue; + } $appendTextWithLineBreaks(paragraph, segment.text); } } @@ -872,6 +979,16 @@ function collectTerminalContextIds(node: LexicalNode): string[] { return []; } +function collectDiffContextCommentIds(node: LexicalNode): string[] { + if (node instanceof ComposerDiffContextCommentNode) { + return [node.__comment.id]; + } + if ($isElementNode(node)) { + return node.getChildren().flatMap((child) => collectDiffContextCommentIds(child)); + } + return []; +} + export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; @@ -881,6 +998,7 @@ export interface ComposerPromptEditorHandle { cursor: number; expandedCursor: number; terminalContextIds: string[]; + diffContextCommentIds: string[]; }; } @@ -888,17 +1006,20 @@ interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; + diffContextComments: ReadonlyArray; skills: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; onRemoveTerminalContext: (contextId: string) => void; + onRemoveDiffContextComment: (commentId: string) => void; onChange: ( nextValue: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, terminalContextIds: string[], + diffContextCommentIds: string[], ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -1063,7 +1184,9 @@ function ComposerInlineTokenSelectionNormalizePlugin() { function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); - const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); + const { onRemoveTerminalContext, onRemoveDiffContextComment } = useContext( + ComposerInlineTokenActionsContext, + ); useEffect(() => { return editor.registerCommand( @@ -1085,6 +1208,9 @@ function ComposerInlineTokenBackspacePlugin() { if (candidate instanceof ComposerTerminalContextNode) { onRemoveTerminalContext(candidate.__context.id); $setSelectionAtComposerOffset(selectionOffset); + } else if (candidate instanceof ComposerDiffContextCommentNode) { + onRemoveDiffContextComment(candidate.__comment.id); + $setSelectionAtComposerOffset(selectionOffset); } else { $setSelectionAtComposerOffset(tokenStart); } @@ -1123,17 +1249,19 @@ function ComposerInlineTokenBackspacePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, onRemoveTerminalContext]); + }, [editor, onRemoveDiffContextComment, onRemoveTerminalContext]); return null; } function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; + diffContextComments: ReadonlyArray; skills: ReadonlyArray; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); + const diffContextCommentsRef = useRef(props.diffContextComments); const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; @@ -1150,6 +1278,10 @@ function ComposerSurroundSelectionPlugin(props: { terminalContextsRef.current = props.terminalContexts; }, [props.terminalContexts]); + useEffect(() => { + diffContextCommentsRef.current = props.diffContextComments; + }, [props.diffContextComments]); + useEffect(() => { skillMetadataRef.current = skillMetadataByName(props.skills); }, [props.skills]); @@ -1199,7 +1331,12 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); + $setComposerEditorPrompt( + nextValue, + terminalContextsRef.current, + diffContextCommentsRef.current, + skillMetadataRef.current, + ); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, @@ -1401,11 +1538,13 @@ function ComposerPromptEditorInner({ value, cursor, terminalContexts, + diffContextComments, skills, disabled, placeholder, className, onRemoveTerminalContext, + onRemoveDiffContextComment, onChange, onCommandKeyDown, onPaste, @@ -1416,6 +1555,8 @@ function ComposerPromptEditorInner({ const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); + const diffContextCommentsSignature = diffContextCommentSignature(diffContextComments); + const diffContextCommentsSignatureRef = useRef(diffContextCommentsSignature); const skillsSignature = skillSignature(skills); const skillsSignatureRef = useRef(skillsSignature); const skillMetadataRef = useRef(skillMetadataByName(skills)); @@ -1424,11 +1565,12 @@ function ComposerPromptEditorInner({ cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), + diffContextCommentIds: diffContextComments.map((comment) => comment.id), }); const isApplyingControlledUpdateRef = useRef(false); - const terminalContextActions = useMemo( - () => ({ onRemoveTerminalContext }), - [onRemoveTerminalContext], + const inlineTokenActions = useMemo( + () => ({ onRemoveDiffContextComment, onRemoveTerminalContext }), + [onRemoveDiffContextComment, onRemoveTerminalContext], ); useEffect(() => { @@ -1447,11 +1589,14 @@ function ComposerPromptEditorInner({ const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; + const diffCommentsChanged = + diffContextCommentsSignatureRef.current !== diffContextCommentsSignature; const skillsChanged = skillsSignatureRef.current !== skillsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && !contextsChanged && + !diffCommentsChanged && !skillsChanged ) { return; @@ -1462,22 +1607,35 @@ function ComposerPromptEditorInner({ cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), + diffContextCommentIds: diffContextComments.map((comment) => comment.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; + diffContextCommentsSignatureRef.current = diffContextCommentsSignature; skillsSignatureRef.current = skillsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { + if ( + previousSnapshot.value === value && + !contextsChanged && + !diffCommentsChanged && + !skillsChanged && + !isFocused + ) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; + previousSnapshot.value !== value || contextsChanged || diffCommentsChanged || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + $setComposerEditorPrompt( + value, + terminalContexts, + diffContextComments, + skillMetadataRef.current, + ); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1486,7 +1644,16 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); + }, [ + cursor, + diffContextComments, + diffContextCommentsSignature, + editor, + skillsSignature, + terminalContexts, + terminalContextsSignature, + value, + ]); const focusAt = useCallback( (nextCursor: number) => { @@ -1502,6 +1669,7 @@ function ComposerPromptEditorInner({ cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, + diffContextCommentIds: snapshotRef.current.diffContextCommentIds, }; onChangeRef.current( snapshotRef.current.value, @@ -1509,6 +1677,7 @@ function ComposerPromptEditorInner({ snapshotRef.current.expandedCursor, false, snapshotRef.current.terminalContextIds, + snapshotRef.current.diffContextCommentIds, ); }, [editor], @@ -1519,6 +1688,7 @@ function ComposerPromptEditorInner({ cursor: number; expandedCursor: number; terminalContextIds: string[]; + diffContextCommentIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -1537,11 +1707,13 @@ function ComposerPromptEditorInner({ $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const terminalContextIds = collectTerminalContextIds($getRoot()); + const diffContextCommentIds = collectDiffContextCommentIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + diffContextCommentIds, }; }); snapshotRef.current = snapshot; @@ -1585,13 +1757,20 @@ function ComposerPromptEditorInner({ $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const terminalContextIds = collectTerminalContextIds($getRoot()); + const diffContextCommentIds = collectDiffContextCommentIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) + previousSnapshot.terminalContextIds.every( + (id, index) => id === terminalContextIds[index], + ) && + previousSnapshot.diffContextCommentIds.length === diffContextCommentIds.length && + previousSnapshot.diffContextCommentIds.every( + (id, index) => id === diffContextCommentIds[index], + ) ) { return; } @@ -1603,6 +1782,7 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + diffContextCommentIds, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || @@ -1613,12 +1793,13 @@ function ComposerPromptEditorInner({ nextExpandedCursor, cursorAdjacentToMention, terminalContextIds, + diffContextCommentIds, ); }); }, []); return ( - + } placeholder={ - terminalContexts.length > 0 ? null : ( + terminalContexts.length > 0 || diffContextComments.length > 0 ? null : ( {placeholder} @@ -1644,13 +1825,17 @@ function ComposerPromptEditorInner({ /> - + - + ); } @@ -1662,11 +1847,13 @@ export const ComposerPromptEditor = forwardRef< value, cursor, terminalContexts, + diffContextComments, skills, disabled, placeholder, className, onRemoveTerminalContext, + onRemoveDiffContextComment, onChange, onCommandKeyDown, onPaste, @@ -1675,16 +1862,23 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); + const initialDiffContextCommentsRef = useRef(diffContextComments); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], + nodes: [ + ComposerMentionNode, + ComposerSkillNode, + ComposerTerminalContextNode, + ComposerDiffContextCommentNode, + ], editorState: () => { $setComposerEditorPrompt( initialValueRef.current, initialTerminalContextsRef.current, + initialDiffContextCommentsRef.current, initialSkillMetadataRef.current, ); }, @@ -1701,10 +1895,12 @@ export const ComposerPromptEditor = forwardRef< value={value} cursor={cursor} terminalContexts={terminalContexts} + diffContextComments={diffContextComments} skills={skills} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} + onRemoveDiffContextComment={onRemoveDiffContextComment} onChange={onChange} onPaste={onPaste} editorRef={ref} diff --git a/apps/web/src/components/DiffContextCommentDraft.tsx b/apps/web/src/components/DiffContextCommentDraft.tsx new file mode 100644 index 00000000000..d0c80489548 --- /dev/null +++ b/apps/web/src/components/DiffContextCommentDraft.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, type ReactNode } 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}`; +} + +const DIFF_CONTEXT_COMMENT_CARD_STYLE = { + width: "min(44rem, calc(100cqw - 3.5rem), calc(100vw - 7.5rem))", + maxWidth: "100%", +} as const; + +function DiffContextCommentCardFrame({ children }: { children: ReactNode }) { + return ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {children} + + ); +} + +export function DiffContextCommentPreview(props: { body: string; onEdit: () => void }) { + const { body, onEdit } = props; + + return ( + + + {body} + + + ); +} + +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 ( + + + + onBodyChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onCancel(); + }} + placeholder="Request change" + aria-label={`Comment on ${filePath}:${formatLineRange(lineStart, lineEnd)}`} + className="min-h-[200px] w-full resize-none bg-transparent px-3 py-3 pb-15 text-sm text-foreground outline-none placeholder:text-muted-foreground/70 sm:px-4 sm:py-4" + /> + + + {error ? {error} : null} + + + {onDelete ? ( + + Delete + + ) : null} + + Cancel + + + {submitLabel} + + + + + + + ); +} diff --git a/apps/web/src/components/DiffPanel.logic.test.ts b/apps/web/src/components/DiffPanel.logic.test.ts new file mode 100644 index 00000000000..cd2208bd763 --- /dev/null +++ b/apps/web/src/components/DiffPanel.logic.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { FileDiffMetadata } from "@pierre/diffs/react"; +import { areSelectedLineRangesEqual, buildFileKeyByPathIndex } from "./DiffPanel.logic"; + +function makeFileDiff( + input: Partial>, +): FileDiffMetadata { + return { + name: input.name ?? null, + prevName: input.prevName ?? null, + cacheKey: input.cacheKey ?? null, + } as FileDiffMetadata; +} + +describe("buildFileKeyByPathIndex", () => { + it("indexes both the previous and current path for renamed files", () => { + const renamedFile = makeFileDiff({ + name: "b/src/new-name.ts", + prevName: "a/src/old-name.ts", + cacheKey: "rename-cache-key", + }); + + const fileKeyByPath = buildFileKeyByPathIndex([renamedFile]); + + expect(fileKeyByPath.get("src/old-name.ts")).toBe("rename-cache-key"); + expect(fileKeyByPath.get("src/new-name.ts")).toBe("rename-cache-key"); + }); + + it("keeps a single entry when the normalized paths are identical", () => { + const unchangedFile = makeFileDiff({ + name: "b/src/example.ts", + prevName: "a/src/example.ts", + cacheKey: "same-cache-key", + }); + + const fileKeyByPath = buildFileKeyByPathIndex([unchangedFile]); + + expect([...fileKeyByPath.entries()]).toEqual([["src/example.ts", "same-cache-key"]]); + }); +}); + +describe("areSelectedLineRangesEqual", () => { + it("treats forward and backward single-side selections as equal", () => { + expect( + areSelectedLineRangesEqual( + { start: 5, end: 10, side: "additions" }, + { start: 10, end: 5, side: "additions" }, + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/DiffPanel.logic.ts b/apps/web/src/components/DiffPanel.logic.ts new file mode 100644 index 00000000000..defaeb54eac --- /dev/null +++ b/apps/web/src/components/DiffPanel.logic.ts @@ -0,0 +1,430 @@ +import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"; +import { type FileDiffMetadata } from "@pierre/diffs/react"; +import { type ScopedThreadRef, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { + type DiffContextCommentSide, + type DiffContextCommentDraft, +} from "../lib/diffContextComments"; +import { randomUUID } from "../lib/utils"; + +export type DiffCommentAnnotationMetadata = + | { + kind: "draft-comment"; + filePath: string; + lineStart: number; + lineEnd: number; + } + | { + kind: "saved-comment"; + commentId: string; + filePath: string; + lineStart: number; + lineEnd: number; + isEditing: boolean; + }; + +export type DiffCommentSelection = { + file: FileDiffMetadata; + fileKey: string; + range: SelectedLineRange; +}; + +const EMPTY_PENDING_DIFF_CONTEXT_COMMENTS: readonly DiffContextCommentDraft[] = []; + +function normalizeDiffPath(raw: string | null | undefined): string { + if (!raw) { + return ""; + } + if (raw.startsWith("a/") || raw.startsWith("b/")) { + return raw.slice(2); + } + return raw; +} + +export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { + return normalizeDiffPath(fileDiff.name ?? fileDiff.prevName); +} + +export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { + return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; +} + +export function buildFileKeyByPathIndex( + renderableFiles: ReadonlyArray, +): Map { + const fileKeyByPath = new Map(); + + for (const fileDiff of renderableFiles) { + const fileKey = buildFileDiffRenderKey(fileDiff); + const candidatePaths = [normalizeDiffPath(fileDiff.prevName), normalizeDiffPath(fileDiff.name)]; + + for (const filePath of candidatePaths) { + if (!filePath) { + continue; + } + fileKeyByPath.set(filePath, fileKey); + } + } + + return fileKeyByPath; +} + +function toNormalizedLineRange(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) { + return range; + } + + return { + start: range.end, + end: range.start, + ...((range.endSide ?? range.side) ? { side: range.endSide ?? range.side } : {}), + ...(range.endSide ? { endSide: range.side } : {}), + }; +} + +function toLineRange(range: SelectedLineRange): { start: number; end: number } { + const normalizedRange = toNormalizedLineRange(range); + return { + start: normalizedRange.start, + end: normalizedRange.end, + }; +} + +export function areSelectedLineRangesEqual( + left: SelectedLineRange | null, + right: SelectedLineRange | null, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + + const normalizedLeft = toNormalizedLineRange(left); + const normalizedRight = toNormalizedLineRange(right); + return ( + normalizedLeft.start === normalizedRight.start && + normalizedLeft.end === normalizedRight.end && + normalizedLeft.side === normalizedRight.side && + normalizedLeft.endSide === normalizedRight.endSide + ); +} + +function resolveCommentFilePath( + fileDiff: FileDiffMetadata, + side: DiffContextCommentSide, +): string | null { + return normalizeDiffPath( + side === "deletions" + ? (fileDiff.prevName ?? fileDiff.name) + : (fileDiff.name ?? fileDiff.prevName), + ); +} + +export function useDiffContextCommentDrafts(args: { + activeThreadId: ThreadId | null; + activeThreadRef: ScopedThreadRef | null; + selectedTurnId: TurnId | null; + renderableFiles: ReadonlyArray; +}) { + const { activeThreadId, activeThreadRef, selectedTurnId, renderableFiles } = args; + const addDiffContextComment = useComposerDraftStore((store) => store.addDiffContextComment); + const updateDiffContextComment = useComposerDraftStore((store) => store.updateDiffContextComment); + const removeDiffContextComment = useComposerDraftStore((store) => store.removeDiffContextComment); + const pendingDiffContextComments = useComposerDraftStore((state) => + activeThreadRef + ? (state.getComposerDraft(activeThreadRef)?.diffContextComments ?? + EMPTY_PENDING_DIFF_CONTEXT_COMMENTS) + : EMPTY_PENDING_DIFF_CONTEXT_COMMENTS, + ); + const [manualCommentSelection, setManualCommentSelection] = useState( + null, + ); + const [manualCommentBody, setManualCommentBody] = useState(""); + const [manualCommentError, setManualCommentError] = useState(""); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingCommentBody, setEditingCommentBody] = useState(""); + const [editingCommentError, setEditingCommentError] = useState(""); + const suppressLineSelectionUntilRef = useRef(0); + + const visiblePendingDiffContextComments = useMemo( + () => pendingDiffContextComments.filter((comment) => comment.turnId === selectedTurnId), + [pendingDiffContextComments, selectedTurnId], + ); + const selectedCommentRange = manualCommentSelection?.range ?? null; + const selectedCommentLineRange = useMemo( + () => (selectedCommentRange ? toLineRange(selectedCommentRange) : null), + [selectedCommentRange], + ); + const selectedCommentSide = (selectedCommentRange?.side ?? "additions") as DiffContextCommentSide; + + const lineAnnotationsByFileKey = useMemo< + Record[]> + >(() => { + const annotationsByFileKey: Record< + string, + DiffLineAnnotation[] + > = {}; + const fileKeyByPath = buildFileKeyByPathIndex(renderableFiles); + + for (const comment of visiblePendingDiffContextComments) { + const fileKey = fileKeyByPath.get(comment.filePath); + if (!fileKey) { + continue; + } + + const annotations = annotationsByFileKey[fileKey] ?? []; + annotations.push({ + side: comment.side, + lineNumber: comment.lineEnd, + metadata: { + kind: "saved-comment", + commentId: comment.id, + filePath: comment.filePath, + lineStart: comment.lineStart, + lineEnd: comment.lineEnd, + isEditing: editingCommentId === comment.id, + }, + }); + annotationsByFileKey[fileKey] = annotations; + } + + if (!manualCommentSelection || !selectedCommentLineRange) { + return annotationsByFileKey; + } + + const filePath = resolveCommentFilePath(manualCommentSelection.file, selectedCommentSide); + if (!filePath) { + return annotationsByFileKey; + } + + const annotations = annotationsByFileKey[manualCommentSelection.fileKey] ?? []; + annotations.push({ + side: selectedCommentSide, + lineNumber: selectedCommentLineRange.end, + metadata: { + kind: "draft-comment", + filePath, + lineStart: selectedCommentLineRange.start, + lineEnd: selectedCommentLineRange.end, + }, + }); + annotationsByFileKey[manualCommentSelection.fileKey] = annotations; + + return annotationsByFileKey; + }, [ + editingCommentId, + manualCommentSelection, + renderableFiles, + selectedCommentLineRange, + selectedCommentSide, + visiblePendingDiffContextComments, + ]); + + const selectedLinesForFileKey = useMemo( + () => + manualCommentSelection + ? { + fileKey: manualCommentSelection.fileKey, + range: manualCommentSelection.range, + } + : null, + [manualCommentSelection], + ); + + useEffect(() => { + setManualCommentSelection(null); + setManualCommentBody(""); + setManualCommentError(""); + setEditingCommentId(null); + setEditingCommentBody(""); + setEditingCommentError(""); + }, [activeThreadRef, selectedTurnId]); + + useEffect(() => { + if (!editingCommentId) { + return; + } + + const stillVisible = visiblePendingDiffContextComments.some( + (comment) => comment.id === editingCommentId, + ); + if (stillVisible) { + return; + } + + setEditingCommentId(null); + setEditingCommentBody(""); + setEditingCommentError(""); + }, [editingCommentId, visiblePendingDiffContextComments]); + + const handleManualCommentSelectionChange = useCallback( + (input: { file: FileDiffMetadata; fileKey: string; range: SelectedLineRange | null }) => { + if (input.range && Date.now() < suppressLineSelectionUntilRef.current) { + return; + } + + if (!input.range) { + setManualCommentSelection((current) => { + if (current?.fileKey !== input.fileKey) { + return current; + } + return null; + }); + setManualCommentError(""); + return; + } + + setEditingCommentId(null); + setEditingCommentBody(""); + setEditingCommentError(""); + + const nextRange = toNormalizedLineRange(input.range); + setManualCommentSelection((current) => { + if ( + current && + current.fileKey === input.fileKey && + areSelectedLineRangesEqual(current.range, nextRange) + ) { + return current; + } + + return { + file: input.file, + fileKey: input.fileKey, + range: nextRange, + }; + }); + setManualCommentError(""); + }, + [], + ); + + const clearManualCommentSelection = useCallback(() => { + suppressLineSelectionUntilRef.current = Date.now() + 500; + setManualCommentSelection(null); + setManualCommentBody(""); + setManualCommentError(""); + }, []); + + const cancelEditingComment = useCallback(() => { + setEditingCommentId(null); + setEditingCommentBody(""); + setEditingCommentError(""); + }, []); + + const beginEditingComment = useCallback((comment: DiffContextCommentDraft) => { + setManualCommentSelection(null); + setManualCommentBody(""); + setManualCommentError(""); + setEditingCommentId(comment.id); + setEditingCommentBody(comment.body); + setEditingCommentError(""); + }, []); + + const submitManualComment = useCallback(() => { + if ( + !activeThreadId || + !activeThreadRef || + !manualCommentSelection || + !selectedCommentLineRange + ) { + setManualCommentError("Select at least one line before sending."); + return; + } + + const commentBody = manualCommentBody.trim(); + if (commentBody.length === 0) { + setManualCommentError("Comment text is required."); + return; + } + + const endSide = manualCommentSelection.range.endSide; + if (endSide && endSide !== selectedCommentSide) { + setManualCommentError("Selection crosses additions and deletions. Select one side only."); + return; + } + + const filePath = resolveCommentFilePath(manualCommentSelection.file, selectedCommentSide); + if (!filePath) { + setManualCommentError("Unable to resolve the selected file path."); + return; + } + + addDiffContextComment(activeThreadRef, { + id: randomUUID(), + threadId: activeThreadId, + turnId: selectedTurnId, + filePath, + lineStart: selectedCommentLineRange.start, + lineEnd: selectedCommentLineRange.end, + side: selectedCommentSide, + body: commentBody, + createdAt: new Date().toISOString(), + }); + setEditingCommentId(null); + setEditingCommentBody(""); + setEditingCommentError(""); + clearManualCommentSelection(); + }, [ + activeThreadId, + activeThreadRef, + addDiffContextComment, + clearManualCommentSelection, + manualCommentBody, + manualCommentSelection, + selectedCommentLineRange, + selectedCommentSide, + selectedTurnId, + ]); + + const saveEditingComment = useCallback(() => { + if (!activeThreadRef || !editingCommentId) { + return; + } + + const commentBody = editingCommentBody.trim(); + if (commentBody.length === 0) { + setEditingCommentError("Comment text is required."); + return; + } + + updateDiffContextComment(activeThreadRef, editingCommentId, { + body: commentBody, + }); + setEditingCommentId(null); + setEditingCommentBody(""); + setEditingCommentError(""); + }, [activeThreadRef, editingCommentBody, editingCommentId, updateDiffContextComment]); + + const deleteEditingComment = useCallback(() => { + if (!activeThreadRef || !editingCommentId) { + return; + } + + removeDiffContextComment(activeThreadRef, editingCommentId); + cancelEditingComment(); + }, [activeThreadRef, cancelEditingComment, editingCommentId, removeDiffContextComment]); + + return { + editingCommentBody, + editingCommentError, + lineAnnotationsByFileKey, + manualCommentBody, + manualCommentError, + selectedLinesForFileKey, + visiblePendingDiffContextComments, + beginEditingComment, + cancelEditingComment, + clearManualCommentSelection, + deleteEditingComment, + handleManualCommentSelectionChange, + saveEditingComment, + setEditingCommentBody, + setManualCommentBody, + submitManualComment, + }; +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e6dbb57cc7d..03620a82785 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,4 +1,4 @@ -import { parsePatchFiles } from "@pierre/diffs"; +import { type DiffLineAnnotation, parsePatchFiles } from "@pierre/diffs"; import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; @@ -36,6 +36,16 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { + buildFileDiffRenderKey, + type DiffCommentAnnotationMetadata, + resolveFileDiffPath, + useDiffContextCommentDrafts, +} from "./DiffPanel.logic"; +import { + DiffContextCommentDraft as DiffContextCommentDraftCard, + DiffContextCommentPreview, +} from "./DiffContextCommentDraft"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; @@ -148,18 +158,6 @@ function getRenderablePatch( } } -function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.name ?? fileDiff.prevName ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; -} - -function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { - return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; -} - interface DiffPanelProps { mode?: DiffPanelMode; } @@ -187,6 +185,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const activeThread = useStore( useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); + const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => activeThread && activeProjectId @@ -313,6 +312,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }), ); }, [renderablePatch]); + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); + const { + editingCommentBody, + editingCommentError, + lineAnnotationsByFileKey, + manualCommentBody, + manualCommentError, + selectedLinesForFileKey, + visiblePendingDiffContextComments, + beginEditingComment, + cancelEditingComment, + clearManualCommentSelection, + deleteEditingComment, + handleManualCommentSelectionChange, + saveEditingComment, + setEditingCommentBody, + setManualCommentBody, + submitManualComment, + } = useDiffContextCommentDrafts({ + activeThreadId, + activeThreadRef, + selectedTurnId: selectedTurn?.turnId ?? null, + renderableFiles, + }); useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { @@ -343,6 +372,72 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { [activeCwd], ); + const renderDraftAnnotation = useCallback( + (annotation: DiffLineAnnotation) => { + const { metadata } = annotation; + if (metadata.kind === "draft-comment") { + return ( + + ); + } + + const comment = visiblePendingDiffContextComments.find( + (entry) => entry.id === metadata.commentId, + ); + if (!comment) { + return null; + } + + if (metadata.isEditing) { + return ( + + ); + } + + return ( + beginEditingComment(comment)} + /> + ); + }, + [ + beginEditingComment, + cancelEditingComment, + clearManualCommentSelection, + deleteEditingComment, + editingCommentBody, + editingCommentError, + manualCommentBody, + manualCommentError, + saveEditingComment, + setEditingCommentBody, + setManualCommentBody, + submitManualComment, + visiblePendingDiffContextComments, + ], + ); + const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ @@ -622,9 +717,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > + handleManualCommentSelectionChange({ + file: fileDiff, + fileKey, + range, + }), + onLineSelected: (range) => + handleManualCommentSelectionChange({ + file: fileDiff, + fileKey, + range, + }), overflow: diffWordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3d3b081af99..ca9f8636c21 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -55,6 +55,10 @@ import { insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; +import { + removeInlineDiffContextCommentPlaceholder, + type DiffContextCommentDraft, +} from "../../lib/diffContextComments"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -160,6 +164,23 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); +const syncDiffContextCommentsByIds = ( + comments: ReadonlyArray, + ids: ReadonlyArray, +): DiffContextCommentDraft[] => { + const commentsById = new Map(comments.map((comment) => [comment.id, comment])); + return ids.flatMap((id) => { + const comment = commentsById.get(id); + return comment ? [comment] : []; + }); +}; + +const diffContextCommentIdListsEqual = ( + comments: ReadonlyArray, + ids: ReadonlyArray, +): boolean => + comments.length === ids.length && comments.every((comment, index) => comment.id === ids[index]); + const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; @@ -339,7 +360,9 @@ export interface ChatComposerHandle { getSendContext: () => { prompt: string; images: ComposerImageAttachment[]; + persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + diffContextComments: DiffContextCommentDraft[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -537,6 +560,7 @@ export const ChatComposer = memo( const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; + const pendingDiffContextComments = composerDraft.diffContextComments; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -549,9 +573,15 @@ export const ChatComposer = memo( const removeComposerDraftTerminalContext = useComposerDraftStore( (store) => store.removeTerminalContext, ); + const removeComposerDraftDiffContextComment = useComposerDraftStore( + (store) => store.removeDiffContextComment, + ); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const setComposerDraftDiffContextComments = useComposerDraftStore( + (store) => store.setDiffContextComments, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -672,15 +702,22 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ - const composerSendState = useMemo( - () => - deriveComposerSendState({ - prompt, - imageCount: composerImages.length, - terminalContexts: composerTerminalContexts, - }), - [composerImages.length, composerTerminalContexts, prompt], - ); + const composerSendState = useMemo(() => { + const sendState = deriveComposerSendState({ + prompt, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + }); + return { + ...sendState, + hasSendableContent: sendState.hasSendableContent || pendingDiffContextComments.length > 0, + }; + }, [ + composerImages.length, + composerTerminalContexts, + pendingDiffContextComments.length, + prompt, + ]); // ------------------------------------------------------------------ // Derived: composer trigger / menu @@ -958,6 +995,29 @@ export const ChatComposer = memo( ], ); + const removeComposerDiffContextCommentFromDraft = useCallback( + (commentId: string) => { + const commentIndex = pendingDiffContextComments.findIndex( + (comment) => comment.id === commentId, + ); + if (commentIndex < 0) return; + const removal = removeInlineDiffContextCommentPlaceholder(promptRef.current, commentIndex); + promptRef.current = removal.prompt; + setPrompt(removal.prompt); + removeComposerDraftDiffContextComment(composerDraftTarget, commentId); + const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); + }, + [ + composerDraftTarget, + pendingDiffContextComments, + promptRef, + removeComposerDraftDiffContextComment, + setPrompt, + ], + ); + // ------------------------------------------------------------------ // Sync refs back to parent // ------------------------------------------------------------------ @@ -1197,6 +1257,7 @@ export const ChatComposer = memo( expandedCursor: number, cursorAdjacentToMention: boolean, terminalContextIds: string[], + diffContextCommentIds: string[], ) => { if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); @@ -1220,6 +1281,12 @@ export const ChatComposer = memo( syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } + if (!diffContextCommentIdListsEqual(pendingDiffContextComments, diffContextCommentIds)) { + setComposerDraftDiffContextComments( + composerDraftTarget, + syncDiffContextCommentsByIds(pendingDiffContextComments, diffContextCommentIds), + ); + } setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), @@ -1233,6 +1300,8 @@ export const ChatComposer = memo( setPrompt, composerDraftTarget, composerTerminalContexts, + pendingDiffContextComments, + setComposerDraftDiffContextComments, setComposerDraftTerminalContexts, ], ); @@ -1295,6 +1364,7 @@ export const ChatComposer = memo( cursor: number; expandedCursor: number; terminalContextIds: string[]; + diffContextCommentIds: string[]; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { @@ -1305,8 +1375,9 @@ export const ChatComposer = memo( cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), terminalContextIds: composerTerminalContexts.map((context) => context.id), + diffContextCommentIds: pendingDiffContextComments.map((comment) => comment.id), }; - }, [composerCursor, composerTerminalContexts, promptRef]); + }, [composerCursor, composerTerminalContexts, pendingDiffContextComments, promptRef]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -1620,6 +1691,7 @@ export const ChatComposer = memo( cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), terminalContextIds: composerTerminalContexts.map((context) => context.id), + diffContextCommentIds: pendingDiffContextComments.map((comment) => comment.id), }; const insertion = insertInlineTerminalContextPlaceholder( snapshot.value, @@ -1651,7 +1723,9 @@ export const ChatComposer = memo( getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, + persistedAttachments: composerDraft.persistedAttachments, terminalContexts: composerTerminalContextsRef.current, + diffContextComments: pendingDiffContextComments, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -1662,10 +1736,12 @@ export const ChatComposer = memo( }), [ activeThread, + composerDraft.persistedAttachments, composerDraftTarget, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, + pendingDiffContextComments, promptRef, composerImagesRef, composerTerminalContextsRef, @@ -1840,8 +1916,14 @@ export const ChatComposer = memo( ? composerTerminalContexts : [] } + diffContextComments={ + !isComposerApprovalState && pendingUserInputs.length === 0 + ? pendingDiffContextComments + : [] + } skills={selectedProviderStatus?.skills ?? []} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} + onRemoveDiffContextComment={removeComposerDiffContextCommentFromDraft} onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} onPaste={onComposerPaste} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 7619a635545..9ddcc63316b 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -32,6 +32,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], modelSelectionByProvider: { [provider]: { provider, diff --git a/apps/web/src/components/chat/DiffContextCommentInlineChip.tsx b/apps/web/src/components/chat/DiffContextCommentInlineChip.tsx new file mode 100644 index 00000000000..a3b40218843 --- /dev/null +++ b/apps/web/src/components/chat/DiffContextCommentInlineChip.tsx @@ -0,0 +1,38 @@ +import { MessageSquareIcon } from "lucide-react"; +import { + buildDiffContextCommentsPreviewTitle, + formatDiffContextCommentLabel, + type DiffContextCommentDraft, +} from "../../lib/diffContextComments"; +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function ComposerDiffContextCommentInlineChip(props: { comment: DiffContextCommentDraft }) { + const { comment } = props; + const label = formatDiffContextCommentLabel(comment); + const previewTitle = buildDiffContextCommentsPreviewTitle([comment]); + + return ; +} + +export function DiffContextCommentInlineChip(props: { label: string; tooltipText: string }) { + return ( + + + + {props.label} + + } + /> + + {props.tooltipText} + + + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e4b683592ed..629b147a2ba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,6 +14,11 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; +import { + extractTrailingDiffContextComments, + formatInlineDiffContextCommentLabel, + type ParsedDiffContextCommentEntry, +} from "../../lib/diffContextComments"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, @@ -45,6 +50,7 @@ import { type MessagesTimelineRow, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { DiffContextCommentInlineChip } from "./DiffContextCommentInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, @@ -301,8 +307,12 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { row.message.role === "user" && (() => { const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const extractedDiffComments = extractTrailingDiffContextComments(row.message.text); + const displayedUserMessage = deriveDisplayedUserMessageState( + extractedDiffComments.promptText, + ); const terminalContexts = displayedUserMessage.contexts; + const userMessageCopyText = row.message.text; const canRevertAgentWork = typeof row.revertTurnCount === "number"; return ( @@ -343,17 +353,17 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { )} {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( + terminalContexts.length > 0 || + extractedDiffComments.comments.length > 0) && ( )} - {displayedUserMessage.copyText && ( - - )} + {userMessageCopyText && } {canRevertAgentWork && ( 0 + ? `${props.comment.header}\n${props.comment.body}` + : props.comment.header; + + return ; + }, +); + const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; + diffContextComments: ParsedDiffContextCommentEntry[]; }) { - if (props.terminalContexts.length > 0) { + if (props.terminalContexts.length > 0 || props.diffContextComments.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( props.text, props.terminalContexts, ); - const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts); + const hasEmbeddedDiffLabels = props.diffContextComments.some((comment) => + props.text.includes(formatInlineDiffContextCommentLabel(comment.header)), + ); const inlineNodes: ReactNode[] = []; - if (hasEmbeddedInlineLabels) { + if (hasEmbeddedInlineLabels || hasEmbeddedDiffLabels) { + const inlineEntries = [ + ...props.terminalContexts.map((context) => ({ + kind: "terminal" as const, + key: `user-terminal-context-inline:${context.header}`, + label: formatInlineTerminalContextLabel(context.header), + node: ( + + ), + })), + ...props.diffContextComments.map((comment) => ({ + kind: "diff" as const, + key: `user-diff-context-comment-inline:${comment.header}`, + label: formatInlineDiffContextCommentLabel(comment.header), + node: ( + + ), + })), + ]; + const matchedInlineEntries = inlineEntries + .map((entry) => ({ ...entry, matchIndex: props.text.indexOf(entry.label) })) + .filter((entry) => entry.matchIndex >= 0) + .toSorted((left, right) => left.matchIndex - right.matchIndex); let cursor = 0; - for (const context of props.terminalContexts) { - const label = formatInlineTerminalContextLabel(context.header); - const matchIndex = props.text.indexOf(label, cursor); - if (matchIndex === -1) { - inlineNodes.length = 0; - break; - } - if (matchIndex > cursor) { - inlineNodes.push( - - {props.text.slice(cursor, matchIndex)} - , - ); + if (matchedInlineEntries.length === inlineEntries.length) { + for (const entry of matchedInlineEntries) { + const matchIndex = props.text.indexOf(entry.label, cursor); + if (matchIndex < cursor) { + inlineNodes.length = 0; + break; + } + if (matchIndex > cursor) { + inlineNodes.push( + + {props.text.slice(cursor, matchIndex)} + , + ); + } + inlineNodes.push(entry.node); + cursor = matchIndex + entry.label.length; } - inlineNodes.push( - , - ); - cursor = matchIndex + label.length; } if (inlineNodes.length > 0) { @@ -749,10 +799,26 @@ const UserMessageBody = memo(function UserMessageBody(props: { , ); } + for (const comment of props.diffContextComments) { + inlineNodes.push( + , + ); + inlineNodes.push( + + {" "} + , + ); + } if (props.text.length > 0) { inlineNodes.push({props.text}); - } else if (inlinePrefix.length === 0) { + } else if ( + buildInlineTerminalContextText(props.terminalContexts).length === 0 && + props.diffContextComments.length === 0 + ) { return null; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index dbfbf092878..e8e8a91e4df 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -228,6 +228,7 @@ async function mountClaudePicker(props?: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], modelSelectionByProvider: props?.skipDraftModelOptions ? {} : { @@ -430,6 +431,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], modelSelectionByProvider: { codex: { provider: "codex", @@ -492,6 +494,7 @@ async function mountCursorPicker(props: { model?: string; options?: CursorModelO nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], modelSelectionByProvider: { cursor: { provider: "cursor", @@ -646,6 +649,7 @@ async function mountOpenCodePicker(props: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], modelSelectionByProvider: { opencode: { provider: "opencode", diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index d723114b400..535571039f0 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -5,6 +5,7 @@ import { splitPromptIntoComposerSegments, } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +import { INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER } from "./lib/diffContextComments"; describe("splitPromptIntoComposerSegments", () => { it("splits mention tokens followed by whitespace into mention segments", () => { @@ -82,6 +83,19 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: " " }, ]); }); + + it("keeps inline diff comment placeholders at their prompt positions", () => { + expect( + splitPromptIntoComposerSegments( + `Inspect ${INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER}@AGENTS.md please`, + ), + ).toEqual([ + { type: "text", text: "Inspect " }, + { type: "diff-context-comment", comment: null }, + { type: "mention", path: "AGENTS.md" }, + { type: "text", text: " please" }, + ]); + }); }); describe("selectionTouchesMentionBoundary", () => { diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 9f4492cabfb..997f9612288 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -2,6 +2,10 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "./lib/terminalContext"; +import { + INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, + type DiffContextCommentDraft, +} from "./lib/diffContextComments"; export type ComposerPromptSegment = | { @@ -19,6 +23,10 @@ export type ComposerPromptSegment = | { type: "terminal-context"; context: TerminalContextDraft | null; + } + | { + type: "diff-context-comment"; + comment: DiffContextCommentDraft | null; }; const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; @@ -94,13 +102,20 @@ function forEachPromptSegmentSlice( | { type: "terminal-context"; promptOffset: number; + } + | { + type: "diff-context-comment"; + promptOffset: number; }, ) => boolean | void, ): boolean { let textCursor = 0; for (let index = 0; index < prompt.length; index += 1) { - if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) { + const char = prompt[index]; + const isTerminalContextPlaceholder = char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER; + const isDiffContextCommentPlaceholder = char === INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER; + if (!isTerminalContextPlaceholder && !isDiffContextCommentPlaceholder) { continue; } @@ -114,7 +129,12 @@ function forEachPromptSegmentSlice( ) { return true; } - if (visitor({ type: "terminal-context", promptOffset: index }) === true) { + if ( + visitor({ + type: isTerminalContextPlaceholder ? "terminal-context" : "diff-context-comment", + promptOffset: index, + }) === true + ) { return true; } textCursor = index + 1; @@ -233,6 +253,7 @@ export function selectionTouchesMentionBoundary( export function splitPromptIntoComposerSegments( prompt: string, terminalContexts: ReadonlyArray = [], + diffContextComments: ReadonlyArray = [], ): ComposerPromptSegment[] { if (!prompt) { return []; @@ -240,12 +261,22 @@ export function splitPromptIntoComposerSegments( const segments: ComposerPromptSegment[] = []; let terminalContextIndex = 0; + let diffContextCommentIndex = 0; forEachPromptSegmentSlice(prompt, (slice) => { if (slice.type === "text") { segments.push(...splitPromptTextIntoComposerSegments(slice.text)); return false; } + if (slice.type === "diff-context-comment") { + segments.push({ + type: "diff-context-comment", + comment: diffContextComments[diffContextCommentIndex] ?? null, + }); + diffContextCommentIndex += 1; + return false; + } + segments.push({ type: "terminal-context", context: terminalContexts[terminalContextIndex] ?? null, diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index fb63d2581c7..52222d5bd72 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,5 +1,6 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +import { INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER } from "./lib/diffContextComments"; export type ComposerTriggerKind = "path" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; @@ -16,7 +17,8 @@ const isInlineTokenSegment = ( | { type: "text"; text: string } | { type: "mention" } | { type: "skill" } - | { type: "terminal-context" }, + | { type: "terminal-context" } + | { type: "diff-context-comment" }, ): boolean => segment.type !== "text"; function clampCursor(text: string, cursor: number): number { @@ -30,7 +32,8 @@ function isWhitespace(char: string): boolean { char === "\n" || char === "\t" || char === "\r" || - char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER + char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER || + char === INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER ); } @@ -71,7 +74,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) expandedCursor += expandedLength; continue; } - if (segment.type === "terminal-context") { + if (segment.type === "terminal-context" || segment.type === "diff-context-comment") { if (remaining <= 1) { return expandedCursor + remaining; } @@ -96,7 +99,8 @@ function collapsedSegmentLength( | { type: "text"; text: string } | { type: "mention" } | { type: "skill" } - | { type: "terminal-context" }, + | { type: "terminal-context" } + | { type: "diff-context-comment" }, ): number { if (segment.type === "text") { return segment.text.length; @@ -110,6 +114,7 @@ function clampCollapsedComposerCursorForSegments( | { type: "mention" } | { type: "skill" } | { type: "terminal-context" } + | { type: "diff-context-comment" } >, cursorInput: number, ): number { @@ -165,7 +170,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number collapsedCursor += 1; continue; } - if (segment.type === "terminal-context") { + if (segment.type === "terminal-context" || segment.type === "diff-context-comment") { if (remaining <= 1) { return collapsedCursor + remaining; } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d789d7d510a..65bad052674 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -9,6 +9,7 @@ import { EnvironmentId, ProjectId, ThreadId, + TurnId, type ModelSelection, type ProviderModelOptions, } from "@t3tools/contracts"; @@ -31,6 +32,10 @@ import { insertInlineTerminalContextPlaceholder, type TerminalContextDraft, } from "./lib/terminalContext"; +import { + INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, + type DiffContextCommentDraft, +} from "./lib/diffContextComments"; import { createDebouncedStorage } from "./lib/storage"; function makeImage(input: { @@ -80,6 +85,30 @@ function makeTerminalContext(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.make("thread-comments"), + turnId: input.turnId ? TurnId.make(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", + }; +} + function resetComposerDraftStore() { useComposerDraftStore.setState({ draftsByThreadKey: {}, @@ -245,6 +274,97 @@ 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(threadRef, makeDiffComment({ id: "comment-clear", threadId })); + + useComposerDraftStore.getState().clearComposerContent(threadRef); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); + + it("restores sendable composer content after a failed send", () => { + const image = makeImage({ + id: "img-restore", + previewUrl: "blob:restore", + }); + const terminalContext = makeTerminalContext({ id: "terminal-restore" }); + const comment = makeDiffComment({ + id: "comment-restore", + threadId, + body: "Restore this diff note.", + }); + + useComposerDraftStore.getState().restoreComposerSendContent(threadRef, { + prompt: "Try again", + images: [image], + persistedAttachments: [], + terminalContexts: [terminalContext], + diffContextComments: [comment], + }); + + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.prompt).toBe("Try again"); + expect(draft?.images.map((entry) => entry.id)).toEqual(["img-restore"]); + expect(draft?.terminalContexts.map((entry) => entry.id)).toEqual(["terminal-restore"]); + expect(draft?.diffContextComments.map((entry) => entry.id)).toEqual(["comment-restore"]); + }); +}); + +describe("composerDraftStore diff context comments", () => { + const threadId = ThreadId.make("thread-comments"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("adds, updates, removes, and clears pending diff comments", () => { + const store = useComposerDraftStore.getState(); + + store.addDiffContextComment(threadRef, makeDiffComment({ id: "comment-1", threadId })); + store.addDiffContextComment( + threadRef, + makeDiffComment({ + id: "comment-2", + threadId, + body: "Keep the fallback.", + side: "deletions", + }), + ); + + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.diffContextComments.map((comment) => comment.id), + ).toEqual(["comment-1", "comment-2"]); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe( + INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER.repeat(2), + ); + + store.updateDiffContextComment(threadRef, "comment-1", { + body: "Use the shared helper instead.", + }); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.diffContextComments[0]?.body).toBe( + "Use the shared helper instead.", + ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.diffContextComments[0]?.filePath).toBe( + "src/example.ts", + ); + + store.removeDiffContextComment(threadRef, "comment-2"); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.diffContextComments.map((comment) => comment.id), + ).toEqual(["comment-1"]); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe( + INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, + ); + + store.clearDiffContextComments(threadRef); + store.clearDiffContextComments(threadRef); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); }); describe("composerDraftStore syncPersistedAttachments", () => { @@ -449,6 +569,56 @@ describe("composerDraftStore terminal contexts", () => { ]); }); + it("hydrates persisted diff comments with missing inline placeholders", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const mergedState = persistApi.getOptions().merge( + { + draftsByThreadId: { + [threadId]: { + prompt: "Please handle this", + attachments: [], + diffContextComments: [ + { + id: "comment-rehydrated", + threadId, + turnId: null, + filePath: "src/example.ts", + lineStart: 12, + lineEnd: 12, + side: "additions", + body: "Use the shared helper.", + createdAt: "2026-03-13T12:00:00.000Z", + }, + ], + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectKey: {}, + }, + useComposerDraftStore.getInitialState(), + ); + + const draft = mergedState.draftsByThreadKey[threadKeyFor(threadId)]; + expect(draft?.prompt).toBe(`${INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER}Please handle this`); + expect(draft?.diffContextComments).toMatchObject([ + { + id: "comment-rehydrated", + filePath: "src/example.ts", + lineStart: 12, + lineEnd: 12, + side: "additions", + body: "Use the shared helper.", + }, + ]); + }); + it("sanitizes malformed persisted drafts during merge", () => { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 36304fc0236..9ade8ddffcc 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -16,6 +16,7 @@ import { type ScopedProjectRef, type ScopedThreadRef, ThreadId, + TurnId, } from "@t3tools/contracts"; import { parseScopedProjectKey, @@ -38,6 +39,13 @@ import { ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; +import { + type DiffContextCommentDraft, + type DiffContextCommentDraftUpdate, + ensureInlineDiffContextCommentPlaceholders, + removeInlineDiffContextCommentPlaceholder, + stripInlineDiffContextCommentPlaceholders, +} from "./lib/diffContextComments"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -61,6 +69,10 @@ const composerDebouncedStorage = createDebouncedStorage( COMPOSER_PERSIST_DEBOUNCE_MS, ); +export function flushComposerDraftStorage(): void { + composerDebouncedStorage.flush(); +} + // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { @@ -93,10 +105,23 @@ const PersistedTerminalContextDraft = Schema.Struct({ }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; +const PersistedDiffContextCommentDraft = Schema.Struct({ + id: Schema.String, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + filePath: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, + side: Schema.Literals(["additions", "deletions"]), + body: Schema.String, + createdAt: Schema.String, +}); + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), + diffContextComments: Schema.optionalKey(Schema.Array(PersistedDiffContextCommentDraft)), modelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), @@ -202,6 +227,7 @@ export interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + diffContextComments: DiffContextCommentDraft[]; modelSelectionByProvider: Partial>; activeProvider: ProviderKind | null; runtimeMode: RuntimeMode | null; @@ -330,6 +356,10 @@ interface ComposerDraftStoreState { setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; + setDiffContextComments: ( + threadRef: ComposerThreadTarget, + comments: DiffContextCommentDraft[], + ) => void; setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, @@ -369,11 +399,32 @@ interface ComposerDraftStoreState { addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; + addDiffContextComment: ( + threadRef: ComposerThreadTarget, + comment: DiffContextCommentDraft, + ) => void; + updateDiffContextComment: ( + threadRef: ComposerThreadTarget, + commentId: string, + updates: DiffContextCommentDraftUpdate, + ) => void; + removeDiffContextComment: (threadRef: ComposerThreadTarget, commentId: string) => void; + clearDiffContextComments: (threadRef: ComposerThreadTarget) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, attachments: PersistedComposerImageAttachment[], ) => void; + restoreComposerSendContent: ( + threadRef: ComposerThreadTarget, + snapshot: { + prompt: string; + images: ComposerImageAttachment[]; + persistedAttachments: PersistedComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + diffContextComments: DiffContextCommentDraft[]; + }, + ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; } @@ -424,9 +475,11 @@ const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; +const EMPTY_DIFF_CONTEXT_COMMENTS: DiffContextCommentDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +Object.freeze(EMPTY_DIFF_CONTEXT_COMMENTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -440,6 +493,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, + diffContextComments: EMPTY_DIFF_CONTEXT_COMMENTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, @@ -453,6 +507,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -517,12 +572,81 @@ function normalizeTerminalContextsForThread( return normalizedContexts; } +function normalizeDiffContextCommentDraft(value: unknown): DiffContextCommentDraft | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const id = candidate.id; + const threadId = candidate.threadId; + const turnId = candidate.turnId; + const filePath = candidate.filePath; + const lineStart = candidate.lineStart; + const lineEnd = candidate.lineEnd; + const side = candidate.side; + const body = candidate.body; + const createdAt = candidate.createdAt; + if ( + typeof id !== "string" || + id.length === 0 || + typeof threadId !== "string" || + threadId.length === 0 || + !(turnId === null || (typeof turnId === "string" && turnId.length > 0)) || + typeof filePath !== "string" || + filePath.length === 0 || + typeof lineStart !== "number" || + !Number.isFinite(lineStart) || + typeof lineEnd !== "number" || + !Number.isFinite(lineEnd) || + (side !== "additions" && side !== "deletions") || + typeof body !== "string" || + typeof createdAt !== "string" || + createdAt.length === 0 + ) { + return null; + } + return { + id, + threadId: ThreadId.make(threadId), + turnId: turnId === null ? null : TurnId.make(turnId), + filePath, + lineStart, + lineEnd, + side, + body, + createdAt, + }; +} + +function normalizeDiffContextCommentsForThread( + threadId: ThreadId, + comments: ReadonlyArray, +): DiffContextCommentDraft[] { + const existingIds = new Set(); + const normalizedComments: DiffContextCommentDraft[] = []; + + for (const comment of comments) { + const normalizedComment = normalizeDiffContextCommentDraft({ + ...comment, + threadId, + }); + if (!normalizedComment || existingIds.has(normalizedComment.id)) { + continue; + } + normalizedComments.push(normalizedComment); + existingIds.add(normalizedComment.id); + } + + return normalizedComments; +} + function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.diffContextComments.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -1387,6 +1511,12 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const diffContextComments = Array.isArray(draftCandidate.diffContextComments) + ? draftCandidate.diffContextComments.flatMap((entry) => { + const normalized = normalizeDiffContextCommentDraft(entry); + return normalized ? [normalized] : []; + }) + : []; const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode) ? draftCandidate.runtimeMode : null; @@ -1394,9 +1524,9 @@ function normalizePersistedDraftsByThreadId( draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" ? draftCandidate.interactionMode : null; - const prompt = ensureInlineTerminalContextPlaceholders( - promptCandidate, - terminalContexts.length, + const prompt = ensureInlineDiffContextCommentPlaceholders( + ensureInlineTerminalContextPlaceholders(promptCandidate, terminalContexts.length), + diffContextComments.length, ); // If the draft already has the v3 shape, use it directly const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; @@ -1450,6 +1580,7 @@ function normalizePersistedDraftsByThreadId( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && + diffContextComments.length === 0 && !hasModelData && !runtimeMode && !interactionMode @@ -1472,6 +1603,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), + ...(diffContextComments.length > 0 ? { diffContextComments } : {}), ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), @@ -1549,6 +1681,7 @@ function partializeComposerDraftStoreState( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.diffContextComments.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1571,6 +1704,9 @@ function partializeComposerDraftStoreState( })), } : {}), + ...(draft.diffContextComments.length > 0 + ? { diffContextComments: draft.diffContextComments } + : {}), ...(hasModelData ? { modelSelectionByProvider: draft.modelSelectionByProvider, @@ -1798,6 +1934,7 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], + diffContextComments: [...(persistedDraft.diffContextComments ?? [])], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -2251,6 +2388,32 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + setDiffContextComments: (threadRef, comments) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; + } + const normalizedComments = normalizeDiffContextCommentsForThread(threadId, comments); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt: ensureInlineDiffContextCommentPlaceholders( + existing.prompt, + normalizedComments.length, + ), + diffContextComments: normalizedComments, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, setModelSelection: (threadRef, modelSelection) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2706,6 +2869,125 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addDiffContextComment: (threadRef, comment) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; + } + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const acceptedComments = normalizeDiffContextCommentsForThread(threadId, [ + ...existing.diffContextComments, + comment, + ]).slice(existing.diffContextComments.length); + if (acceptedComments.length === 0) { + return state; + } + const nextDiffContextComments = [...existing.diffContextComments, ...acceptedComments]; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + prompt: ensureInlineDiffContextCommentPlaceholders( + existing.prompt, + nextDiffContextComments.length, + ), + diffContextComments: nextDiffContextComments, + }, + }, + }; + }); + }, + updateDiffContextComment: (threadRef, commentId, updates) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || commentId.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const commentIndex = current.diffContextComments.findIndex( + (comment) => comment.id === commentId, + ); + if (commentIndex === -1) { + return state; + } + const nextDiffContextComments = current.diffContextComments.slice(); + nextDiffContextComments[commentIndex] = { + ...nextDiffContextComments[commentIndex]!, + body: updates.body, + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...current, + diffContextComments: nextDiffContextComments, + }, + }, + }; + }); + }, + removeDiffContextComment: (threadRef, commentId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || commentId.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const commentIndex = current.diffContextComments.findIndex( + (comment) => comment.id === commentId, + ); + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: + commentIndex >= 0 + ? removeInlineDiffContextCommentPlaceholder(current.prompt, commentIndex).prompt + : current.prompt, + diffContextComments: current.diffContextComments.filter( + (comment) => comment.id !== commentId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearDiffContextComments: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.diffContextComments.length === 0) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: stripInlineDiffContextCommentPlaceholders(current.prompt), + diffContextComments: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearPersistedAttachments: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2761,6 +3043,39 @@ const composerDraftStore = create()( verifyPersistedAttachments(threadKey, attachments, set); }); }, + restoreComposerSendContent: (threadRef, snapshot) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const persistedAttachmentIds = new Set( + snapshot.persistedAttachments.map((attachment) => attachment.id), + ); + const imageIds = new Set(snapshot.images.map((image) => image.id)); + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: snapshot.prompt, + images: [...snapshot.images], + nonPersistedImageIds: snapshot.images + .map((image) => image.id) + .filter((imageId) => !persistedAttachmentIds.has(imageId)), + persistedAttachments: snapshot.persistedAttachments.filter((attachment) => + imageIds.has(attachment.id), + ), + terminalContexts: [...snapshot.terminalContexts], + diffContextComments: [...snapshot.diffContextComments], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearComposerContent: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2778,6 +3093,7 @@ const composerDraftStore = create()( nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + diffContextComments: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/lib/diffContextComments.test.ts b/apps/web/src/lib/diffContextComments.test.ts new file mode 100644 index 00000000000..e498086e553 --- /dev/null +++ b/apps/web/src/lib/diffContextComments.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { type ThreadId } from "@t3tools/contracts"; + +import { + appendDiffContextCommentsToPrompt, + extractTrailingDiffContextComments, + INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, + materializeInlineDiffContextCommentPrompt, + type DiffContextCommentDraft, +} from "./diffContextComments"; + +function makeComment(input: { + id: string; + filePath: string; + lineStart: number; + lineEnd?: number; + side?: "additions" | "deletions"; + body: string; +}): DiffContextCommentDraft { + return { + id: input.id, + threadId: "thread-diff-comments" as ThreadId, + turnId: null, + filePath: input.filePath, + lineStart: input.lineStart, + lineEnd: input.lineEnd ?? input.lineStart, + side: input.side ?? "additions", + body: input.body, + createdAt: "2026-03-12T00:00:00.000Z", + }; +} + +describe("diffContextComments", () => { + it("preserves multiline comment bodies in the serialized prompt block", () => { + const prompt = appendDiffContextCommentsToPrompt("Please address these.", [ + makeComment({ + id: "comment-1", + filePath: "src/example.ts", + lineStart: 12, + body: "Keep the guard.\n- This branch is still reachable.\n\nAdd a test.", + }), + ]); + + expect(prompt).toContain("- src/example.ts:+12:"); + expect(prompt).toContain(" Keep the guard."); + expect(prompt).toContain(" - This branch is still reachable."); + expect(prompt).toContain(" "); + expect(prompt).toContain(" Add a test."); + }); + + it("extracts previews from both multiline and legacy single-line comment blocks", () => { + const multilinePrompt = appendDiffContextCommentsToPrompt("Prompt text", [ + makeComment({ + id: "comment-1", + filePath: "src/example.ts", + lineStart: 12, + lineEnd: 14, + body: "First line\nSecond line", + }), + makeComment({ + id: "comment-2", + filePath: "src/old.ts", + lineStart: 5, + side: "deletions", + body: "Legacy formatting should still parse.", + }), + ]); + + expect(extractTrailingDiffContextComments(multilinePrompt)).toEqual({ + promptText: "Prompt text", + commentCount: 2, + previewTitle: + "src/example.ts:+12-14\nFirst line\nSecond line\n\nsrc/old.ts:-5\nLegacy formatting should still parse.", + comments: [ + { + header: "src/example.ts:+12-14", + body: "First line\nSecond line", + }, + { + header: "src/old.ts:-5", + body: "Legacy formatting should still parse.", + }, + ], + }); + + const legacyPrompt = [ + "Prompt text", + "", + "", + "- src/example.ts:+12: One line only", + "", + ].join("\n"); + + expect(extractTrailingDiffContextComments(legacyPrompt)).toEqual({ + promptText: "Prompt text", + commentCount: 1, + previewTitle: "src/example.ts:+12\nOne line only", + comments: [ + { + header: "src/example.ts:+12", + body: "One line only", + }, + ], + }); + }); + + it("materializes inline diff comment placeholders before appending the hidden block", () => { + const comment = makeComment({ + id: "comment-inline", + filePath: "src/example.ts", + lineStart: 12, + lineEnd: 14, + body: "Use the shared helper.", + }); + + expect(materializeInlineDiffContextCommentPrompt("Fix this", [comment])).toBe("Fix this"); + expect( + appendDiffContextCommentsToPrompt(`Fix ${INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER} please`, [ + comment, + ]), + ).toBe( + [ + "Fix @diff:src/example.ts:+12-14 please", + "", + "", + "- src/example.ts:+12-14:", + " Use the shared helper.", + "", + ].join("\n"), + ); + }); +}); diff --git a/apps/web/src/lib/diffContextComments.ts b/apps/web/src/lib/diffContextComments.ts new file mode 100644 index 00000000000..4c93062754d --- /dev/null +++ b/apps/web/src/lib/diffContextComments.ts @@ -0,0 +1,207 @@ +import { type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + appendPromptContextBlock, + buildPromptContextBlock, + extractTrailingPromptContextBlock, + type PromptContextBlockEntry, +} from "./promptContextBlock"; + +export type DiffContextCommentSide = "additions" | "deletions"; + +export interface DiffContextCommentDraft { + id: string; + threadId: ThreadId; + turnId: TurnId | null; + filePath: string; + lineStart: number; + lineEnd: number; + side: DiffContextCommentSide; + body: string; + createdAt: string; +} + +export interface DiffContextCommentDraftUpdate { + body: string; +} + +export interface ExtractedDiffContextComments { + promptText: string; + commentCount: number; + previewTitle: string | null; + comments: ParsedDiffContextCommentEntry[]; +} + +export type ParsedDiffContextCommentEntry = PromptContextBlockEntry; + +const DIFF_CONTEXT_COMMENTS_BLOCK_TAG = "diff_context_comments"; +export const INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER = "\uE000"; + +function formatSideLabel(side: DiffContextCommentSide): string { + return side === "deletions" ? "-" : "+"; +} + +export function formatDiffContextCommentRange(comment: { + lineStart: number; + lineEnd: number; + side: DiffContextCommentSide; +}): string { + const prefix = formatSideLabel(comment.side); + return comment.lineStart === comment.lineEnd + ? `${prefix}${comment.lineStart}` + : `${prefix}${comment.lineStart}-${comment.lineEnd}`; +} + +export function formatDiffContextCommentLabel(comment: { + filePath: string; + lineStart: number; + lineEnd: number; + side: DiffContextCommentSide; +}): string { + return `${comment.filePath}:${formatDiffContextCommentRange(comment)}`; +} + +export function formatInlineDiffContextCommentLabel( + comment: + | { + filePath: string; + lineStart: number; + lineEnd: number; + side: DiffContextCommentSide; + } + | string, +): string { + const label = typeof comment === "string" ? comment : formatDiffContextCommentLabel(comment); + return `@diff:${label}`; +} + +function normalizeCommentBody(body: string): string { + return body.trim(); +} + +export function buildDiffContextCommentsPreviewTitle( + comments: ReadonlyArray, +): string | null { + if (comments.length === 0) { + return null; + } + + return comments + .map((comment) => `${formatDiffContextCommentLabel(comment)}\n${comment.body.trim()}`) + .join("\n\n"); +} + +export function buildDiffContextCommentsBlock( + comments: ReadonlyArray, +): string { + if (comments.length === 0) { + return ""; + } + + return buildPromptContextBlock( + DIFF_CONTEXT_COMMENTS_BLOCK_TAG, + comments.map((comment) => ({ + header: formatDiffContextCommentLabel(comment), + bodyLines: normalizeCommentBody(comment.body) + .split("\n") + .map((line) => ` ${line}`), + })), + ); +} + +export function appendDiffContextCommentsToPrompt( + prompt: string, + comments: ReadonlyArray, +): string { + const materializedPrompt = materializeInlineDiffContextCommentPrompt(prompt, comments); + const commentBlock = buildDiffContextCommentsBlock(comments); + + return appendPromptContextBlock(materializedPrompt, commentBlock); +} + +export function extractTrailingDiffContextComments(prompt: string): ExtractedDiffContextComments { + const extracted = extractTrailingPromptContextBlock(prompt, DIFF_CONTEXT_COMMENTS_BLOCK_TAG, { + allowSingleLineEntries: true, + }); + + return { + promptText: extracted.promptText, + commentCount: extracted.entries.length, + previewTitle: extracted.previewTitle, + comments: extracted.entries, + }; +} + +export function materializeInlineDiffContextCommentPrompt( + prompt: string, + comments: ReadonlyArray<{ + filePath: string; + lineStart: number; + lineEnd: number; + side: DiffContextCommentSide; + }>, +): string { + let nextCommentIndex = 0; + let result = ""; + + for (const char of prompt) { + if (char !== INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER) { + result += char; + continue; + } + const comment = comments[nextCommentIndex] ?? null; + nextCommentIndex += 1; + if (!comment) { + continue; + } + result += formatInlineDiffContextCommentLabel(comment); + } + + return result; +} + +export function countInlineDiffContextCommentPlaceholders(prompt: string): number { + let count = 0; + for (const char of prompt) { + if (char === INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER) { + count += 1; + } + } + return count; +} + +export function ensureInlineDiffContextCommentPlaceholders( + prompt: string, + diffContextCommentCount: number, +): string { + const missingCount = diffContextCommentCount - countInlineDiffContextCommentPlaceholders(prompt); + if (missingCount <= 0) { + return prompt; + } + return `${INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER.repeat(missingCount)}${prompt}`; +} + +export function stripInlineDiffContextCommentPlaceholders(prompt: string): string { + return prompt.replaceAll(INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER, ""); +} + +export function removeInlineDiffContextCommentPlaceholder( + prompt: string, + contextIndex: number, +): { prompt: string; cursor: number } { + let seenCount = 0; + for (let index = 0; index < prompt.length; index += 1) { + if (prompt[index] !== INLINE_DIFF_CONTEXT_COMMENT_PLACEHOLDER) { + continue; + } + if (seenCount === contextIndex) { + const nextChar = prompt[index + 1]; + const removeEnd = nextChar === " " ? index + 2 : index + 1; + return { + prompt: prompt.slice(0, index) + prompt.slice(removeEnd), + cursor: index, + }; + } + seenCount += 1; + } + return { prompt, cursor: prompt.length }; +} diff --git a/apps/web/src/lib/promptContextBlock.ts b/apps/web/src/lib/promptContextBlock.ts new file mode 100644 index 00000000000..558e2e92f70 --- /dev/null +++ b/apps/web/src/lib/promptContextBlock.ts @@ -0,0 +1,151 @@ +export interface PromptContextBlockEntry { + header: string; + body: string; +} + +export interface PromptContextBlockInputEntry { + header: string; + bodyLines: ReadonlyArray; +} + +interface ExtractPromptContextBlockOptions { + allowSingleLineEntries?: boolean; +} + +export interface ExtractedPromptContextBlock { + promptText: string; + entries: PromptContextBlockEntry[]; + previewTitle: string | null; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function trailingBlockPattern(tagName: string): RegExp { + const escapedTagName = escapeRegExp(tagName); + return new RegExp(`\\n*<${escapedTagName}>\\n([\\s\\S]*?)\\n<\\/${escapedTagName}>\\s*$`); +} + +export function buildPromptContextBlock( + tagName: string, + entries: ReadonlyArray, +): string { + if (entries.length === 0) { + return ""; + } + + const lines: string[] = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; + lines.push(`- ${entry.header}:`); + lines.push(...entry.bodyLines); + if (index < entries.length - 1) { + lines.push(""); + } + } + + return [`<${tagName}>`, ...lines, `${tagName}>`].join("\n"); +} + +export function appendPromptContextBlock(prompt: string, block: string): string { + const trimmedPrompt = prompt.trim(); + if (block.length === 0) { + return trimmedPrompt; + } + return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${block}` : block; +} + +export function formatPromptContextPreviewTitle( + entries: ReadonlyArray, +): string | null { + if (entries.length === 0) { + return null; + } + + return entries + .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header)) + .join("\n\n"); +} + +export function extractTrailingPromptContextBlock( + prompt: string, + tagName: string, + options: ExtractPromptContextBlockOptions = {}, +): ExtractedPromptContextBlock { + const match = trailingBlockPattern(tagName).exec(prompt); + if (!match) { + return { + promptText: prompt, + entries: [], + previewTitle: null, + }; + } + + const entries = parsePromptContextBlockEntries(match[1] ?? "", options); + return { + promptText: prompt.slice(0, match.index).replace(/\n+$/, ""), + entries, + previewTitle: formatPromptContextPreviewTitle(entries), + }; +} + +function parsePromptContextBlockEntries( + block: string, + options: ExtractPromptContextBlockOptions, +): PromptContextBlockEntry[] { + const entries: PromptContextBlockEntry[] = []; + let current: { header: string; bodyLines: string[] } | null = null; + + const commitCurrent = () => { + if (!current) { + return; + } + + entries.push({ + header: current.header, + body: current.bodyLines.join("\n").trimEnd(), + }); + current = null; + }; + + for (const rawLine of block.split("\n")) { + if (options.allowSingleLineEntries) { + const singleLineMatch = /^- (.+?): (.+)$/.exec(rawLine); + if (singleLineMatch) { + commitCurrent(); + entries.push({ + header: singleLineMatch[1]!, + body: singleLineMatch[2]!, + }); + continue; + } + } + + const headerMatch = /^- (.+):$/.exec(rawLine); + if (headerMatch) { + commitCurrent(); + current = { + header: headerMatch[1]!, + bodyLines: [], + }; + continue; + } + + if (!current) { + continue; + } + + if (rawLine.startsWith(" ")) { + current.bodyLines.push(rawLine.slice(2)); + continue; + } + + if (rawLine.length === 0) { + current.bodyLines.push(""); + } + } + + commitCurrent(); + return entries; +} diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 562fe742a15..7623af802fe 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -1,4 +1,10 @@ import { type ThreadId } from "@t3tools/contracts"; +import { + appendPromptContextBlock, + buildPromptContextBlock, + extractTrailingPromptContextBlock, + type PromptContextBlockEntry, +} from "./promptContextBlock"; export interface TerminalContextSelection { terminalId: string; @@ -29,15 +35,11 @@ export interface DisplayedUserMessageState { contexts: ParsedTerminalContextEntry[]; } -export interface ParsedTerminalContextEntry { - header: string; - body: string; -} +export type ParsedTerminalContextEntry = PromptContextBlockEntry; export const INLINE_TERMINAL_CONTEXT_PLACEHOLDER = "\uFFFC"; -const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN = - /\n*\n([\s\S]*?)\n<\/terminal_context>\s*$/; +const TERMINAL_CONTEXT_BLOCK_TAG = "terminal_context"; export function normalizeTerminalContextText(text: string): string { return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); @@ -155,19 +157,14 @@ export function buildTerminalContextBlock( const normalizedContexts = contexts .map((context) => normalizeTerminalContextSelection(context)) .filter((context): context is TerminalContextSelection => context !== null); - if (normalizedContexts.length === 0) { - return ""; - } - const lines: string[] = []; - for (let index = 0; index < normalizedContexts.length; index += 1) { - const context = normalizedContexts[index]!; - lines.push(`- ${formatTerminalContextLabel(context)}:`); - lines.push(...buildTerminalContextBodyLines(context)); - if (index < normalizedContexts.length - 1) { - lines.push(""); - } - } - return ["", ...lines, ""].join("\n"); + + return buildPromptContextBlock( + TERMINAL_CONTEXT_BLOCK_TAG, + normalizedContexts.map((context) => ({ + header: formatTerminalContextLabel(context), + bodyLines: buildTerminalContextBodyLines(context), + })), + ); } export function materializeInlineTerminalContextPrompt( @@ -201,36 +198,18 @@ export function appendTerminalContextsToPrompt( prompt: string, contexts: ReadonlyArray, ): string { - const trimmedPrompt = materializeInlineTerminalContextPrompt(prompt, contexts).trim(); + const materializedPrompt = materializeInlineTerminalContextPrompt(prompt, contexts); const contextBlock = buildTerminalContextBlock(contexts); - if (contextBlock.length === 0) { - return trimmedPrompt; - } - return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${contextBlock}` : contextBlock; + return appendPromptContextBlock(materializedPrompt, contextBlock); } export function extractTrailingTerminalContexts(prompt: string): ExtractedTerminalContexts { - const match = TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN.exec(prompt); - if (!match) { - return { - promptText: prompt, - contextCount: 0, - previewTitle: null, - contexts: [], - }; - } - const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); - const parsedContexts = parseTerminalContextEntries(match[1] ?? ""); + const extracted = extractTrailingPromptContextBlock(prompt, TERMINAL_CONTEXT_BLOCK_TAG); return { - promptText, - contextCount: parsedContexts.length, - previewTitle: - parsedContexts.length > 0 - ? parsedContexts - .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header)) - .join("\n\n") - : null, - contexts: parsedContexts, + promptText: extracted.promptText, + contextCount: extracted.entries.length, + previewTitle: extracted.previewTitle, + contexts: extracted.entries, }; } @@ -245,47 +224,6 @@ export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMe }; } -function parseTerminalContextEntries(block: string): ParsedTerminalContextEntry[] { - const entries: ParsedTerminalContextEntry[] = []; - let current: { header: string; bodyLines: string[] } | null = null; - - const commitCurrent = () => { - if (!current) { - return; - } - entries.push({ - header: current.header, - body: current.bodyLines.join("\n").trimEnd(), - }); - current = null; - }; - - for (const rawLine of block.split("\n")) { - const headerMatch = /^- (.+):$/.exec(rawLine); - if (headerMatch) { - commitCurrent(); - current = { - header: headerMatch[1]!, - bodyLines: [], - }; - continue; - } - if (!current) { - continue; - } - if (rawLine.startsWith(" ")) { - current.bodyLines.push(rawLine.slice(2)); - continue; - } - if (rawLine.length === 0) { - current.bodyLines.push(""); - } - } - - commitCurrent(); - return entries; -} - export function countInlineTerminalContextPlaceholders(prompt: string): number { let count = 0; for (const char of prompt) {
{body}