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 ( + + + + ); +} + +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 ( + +
+
+