diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index b57c13032c..e8a31ef44a 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -24,6 +24,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, + { "key": "mod+b", "command": "sidebar.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -52,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) - `commandPalette.toggle`: open or close the global command palette +- `sidebar.toggle`: open or close the thread sidebar - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/apps/web/src/components/AppSidebarLayout.logic.test.ts b/apps/web/src/components/AppSidebarLayout.logic.test.ts new file mode 100644 index 0000000000..8acb02c957 --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.logic.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { canCollapseAppSidebar } from "./AppSidebarLayout.logic"; + +describe("canCollapseAppSidebar", () => { + it("allows collapse in chat/editor routes", () => { + expect(canCollapseAppSidebar("/")).toBe(true); + expect(canCollapseAppSidebar("/environment-local/thread-123")).toBe(true); + }); + + it("keeps settings routes expanded", () => { + expect(canCollapseAppSidebar("/settings")).toBe(false); + expect(canCollapseAppSidebar("/settings/general")).toBe(false); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.logic.ts b/apps/web/src/components/AppSidebarLayout.logic.ts new file mode 100644 index 0000000000..09871676d2 --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.logic.ts @@ -0,0 +1,3 @@ +export function canCollapseAppSidebar(pathname: string): boolean { + return !pathname.startsWith("/settings"); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e5..bb4a8d2077 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,8 +1,9 @@ import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { Sidebar, SidebarRail } from "./ui/sidebar"; +import { canCollapseAppSidebar } from "./AppSidebarLayout.logic"; import { clearShortcutModifierState, syncShortcutModifierStateFromKeyboardEvent, @@ -13,6 +14,8 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const sidebarCanCollapse = canCollapseAppSidebar(pathname); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -54,10 +57,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + <> - + {sidebarCanCollapse ? : null} {children} - + ); } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 83c90edadd..447adc3872 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,6 +10,7 @@ import { 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, @@ -72,6 +73,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 bf87add28d..adddf6867a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -17,6 +17,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"; @@ -190,7 +191,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 d66d2487ce..21fe1e275d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -132,6 +132,7 @@ import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, + flushComposerDraftStorage, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; @@ -141,6 +142,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"; @@ -653,17 +655,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, @@ -2628,7 +2628,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, @@ -2661,7 +2663,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) { @@ -2671,7 +2675,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, @@ -2708,11 +2713,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({ @@ -2775,6 +2786,7 @@ export default function ChatView(props: ChatViewProps) { } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); + flushComposerDraftStorage(); composerRef.current?.resetCursorState(); let turnStartSucceeded = false; @@ -2877,7 +2889,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); @@ -2891,9 +2905,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, @@ -3505,6 +3524,8 @@ export default function ChatView(props: ChatViewProps) { isElectron ? cn( "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", + "transition-[padding-left] duration-200 ease-linear", + "group-data-[state=collapsed]/sidebar-wrapper:pl-[90px] group-data-[state=collapsed]/sidebar-wrapper:wco:pl-[calc(env(titlebar-area-x)+1em)]", reserveTitleBarControlInset && "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", ) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7862191d40..03697a3093 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -12,7 +12,7 @@ import { type SourceControlRepositoryInfo, } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowDownIcon, @@ -23,6 +23,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PanelLeftIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -115,9 +116,11 @@ import { import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { useSidebar } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { canCollapseAppSidebar } from "./AppSidebarLayout.logic"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; const BROWSE_STALE_TIME_MS = 30_000; @@ -392,6 +395,8 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const { toggleSidebar } = useSidebar(); const setOpen = useCommandPaletteStore((store) => store.setOpen); const openIntent = useCommandPaletteStore((store) => store.openIntent); const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); @@ -402,6 +407,7 @@ function OpenCommandPaletteDialog() { const queryClient = useQueryClient(); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const settings = useSettings(); + const sidebarCanCollapse = canCollapseAppSidebar(pathname); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); @@ -1050,6 +1056,20 @@ function OpenCommandPaletteDialog() { }, }); + if (sidebarCanCollapse) { + actionItems.push({ + kind: "action", + value: "action:toggle-sidebar", + searchTerms: ["sidebar", "toggle", "collapse", "hide", "show"], + title: "Toggle sidebar", + icon: , + shortcutCommand: "sidebar.toggle", + run: async () => { + toggleSidebar(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6f17c86630..1031c7a4e4 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -46,6 +46,7 @@ import { useLayoutEffect, useMemo, useRef, + type ReactElement, } from "react"; import { @@ -62,6 +63,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 { @@ -72,6 +77,7 @@ import { SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { ComposerDiffContextCommentInlineChip } from "./chat/DiffContextCommentInlineChip"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -121,10 +127,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 }) { @@ -425,16 +442,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 ); } @@ -459,6 +542,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) => @@ -821,6 +922,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, + diffContextComments: ReadonlyArray, skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); @@ -828,7 +930,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)); @@ -851,6 +953,12 @@ function $setComposerEditorPrompt( } continue; } + if (segment.type === "diff-context-comment") { + if (segment.comment) { + paragraph.append($createComposerDiffContextCommentNode(segment.comment)); + } + continue; + } $appendTextWithLineBreaks(paragraph, segment.text); } } @@ -865,6 +973,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; @@ -874,6 +992,7 @@ export interface ComposerPromptEditorHandle { cursor: number; expandedCursor: number; terminalContextIds: string[]; + diffContextCommentIds: string[]; }; } @@ -881,17 +1000,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", @@ -1053,7 +1175,9 @@ function ComposerInlineTokenSelectionNormalizePlugin() { function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); - const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); + const { onRemoveTerminalContext, onRemoveDiffContextComment } = use( + ComposerInlineTokenActionsContext, + ); useEffect(() => { return editor.registerCommand( @@ -1075,6 +1199,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); } @@ -1113,17 +1240,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; @@ -1140,6 +1269,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]); @@ -1188,7 +1321,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, @@ -1388,11 +1526,13 @@ function ComposerPromptEditorInner({ value, cursor, terminalContexts, + diffContextComments, skills, disabled, placeholder, className, onRemoveTerminalContext, + onRemoveDiffContextComment, onChange, onCommandKeyDown, onPaste, @@ -1403,6 +1543,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)); @@ -1411,11 +1553,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(() => { @@ -1434,11 +1577,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; @@ -1449,22 +1595,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); @@ -1473,7 +1632,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) => { @@ -1489,6 +1657,7 @@ function ComposerPromptEditorInner({ cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, + diffContextCommentIds: snapshotRef.current.diffContextCommentIds, }; onChangeRef.current( snapshotRef.current.value, @@ -1496,6 +1665,7 @@ function ComposerPromptEditorInner({ snapshotRef.current.expandedCursor, false, snapshotRef.current.terminalContextIds, + snapshotRef.current.diffContextCommentIds, ); }, [editor], @@ -1506,6 +1676,7 @@ function ComposerPromptEditorInner({ cursor: number; expandedCursor: number; terminalContextIds: string[]; + diffContextCommentIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -1524,11 +1695,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; @@ -1572,13 +1745,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; } @@ -1590,6 +1770,7 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + diffContextCommentIds, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || @@ -1600,12 +1781,13 @@ function ComposerPromptEditorInner({ nextExpandedCursor, cursorAdjacentToMention, terminalContextIds, + diffContextCommentIds, ); }); }, []); return ( - +
} placeholder={ - terminalContexts.length > 0 ? null : ( + terminalContexts.length > 0 || diffContextComments.length > 0 ? null : (
{placeholder}
@@ -1631,13 +1813,17 @@ function ComposerPromptEditorInner({ /> - +
-
+ ); } @@ -1645,11 +1831,13 @@ export function ComposerPromptEditor({ value, cursor, terminalContexts, + diffContextComments, skills, disabled, placeholder, className, onRemoveTerminalContext, + onRemoveDiffContextComment, onChange, onCommandKeyDown, onPaste, @@ -1657,16 +1845,23 @@ export function ComposerPromptEditor({ }: ComposerPromptEditorProps) { 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, ); }, @@ -1683,10 +1878,12 @@ export function ComposerPromptEditor({ value={value} cursor={cursor} terminalContexts={terminalContexts} + diffContextComments={diffContextComments} skills={skills} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} + onRemoveDiffContextComment={onRemoveDiffContextComment} onChange={onChange} onPaste={onPaste} editorRef={editorRef} diff --git a/apps/web/src/components/DiffContextCommentDraft.tsx b/apps/web/src/components/DiffContextCommentDraft.tsx new file mode 100644 index 0000000000..d0c8048954 --- /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 ( + +
+
+