From b8498bcb5bbf534b98d865508a8dc0981ffa629f Mon Sep 17 00:00:00 2001 From: Henry Chu Date: Sat, 17 Oct 2026 15:43:25 -0400 Subject: [PATCH] Align ChatMarkdown and FilePreviewPanel with upstream's compiled form A hunk-by-hunk read of the six fork-heavy web files against upstream 13aa882c22 (assetUrls, HtmlRenderFrame, AttachmentFilePreview, RightPanelTabs, FilePreviewPanel, ChatMarkdown) found no missing upstream behaviour beyond the documented cuts, but two files still carried pre-compiler shape that upstream removed in the React Compiler rollout: - ChatMarkdown: 31 useCallback/useMemo wrappers are gone, as upstream's are; `brandLinkIcon` is upstream's `isGitHubHost` again; the dead `MarkdownAstNode` type and `isWindowsAbsolutePath` import are removed; and the lucide and session imports sit in the import block. - FilePreviewPanel: 15 wrappers are gone, including the fork-only ones around `owner`, `handlePendingChange`, and `reloadFile`, and upstream's exhaustive-deps lint note on the breadcrumb scroll effect is back. Every Coder seam in both files is unchanged. ChatView's rewind now reads a message's images concurrently, as upstream's prepareRevertedMessageAttachments fetches them, and creates preview URLs only after every read has succeeded. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ChatMarkdown.tsx | 738 ++++++++---------- apps/web/src/components/ChatView.tsx | 29 +- .../src/components/files/FilePreviewPanel.tsx | 535 ++++++------- 3 files changed, 579 insertions(+), 723 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 993c37d4f457..0a2abd3345d8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -52,6 +52,7 @@ import { WrapTextIcon, type LucideIcon, } from "lucide-react"; +import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import type { EnvironmentId, ServerConfig, @@ -87,11 +88,9 @@ import React, { type MouseEvent as ReactMouseEvent, isValidElement, use, - useCallback, memo, useEffect, useLayoutEffect, - useMemo, useRef, useState, type ReactNode, @@ -110,7 +109,6 @@ import { parseComposerContextHref } from "@t3tools/shared/composerContextReferen import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; -import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { artifactTemplateFromHastProperties, renderCodexInlineDirectivesAsMarkdown, @@ -177,6 +175,7 @@ import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; +import { readEnvironmentScope } from "../state/session"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { @@ -194,11 +193,6 @@ import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; -import { Check } from "lucide"; -import { Copy } from "lucide"; -import { Maximize2 } from "lucide"; -import { Minimize2 } from "lucide"; -import { readEnvironmentScope } from "../state/session"; interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -451,16 +445,6 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): ); } -type MarkdownAstNode = { - type?: string; - meta?: unknown; - url?: string; - data?: { - hProperties?: Record; - }; - children?: MarkdownAstNode[]; -}; - function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -547,7 +531,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { setExpanded((value) => !value); } - const handleCopy = useCallback((format: "markdown" | "csv") => { + const handleCopy = (format: "markdown" | "csv") => { const table = containerRef.current?.querySelector("table"); if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { return; @@ -571,7 +555,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { .catch((cause) => { reportMarkdownActionFailure({ operation: "copy-table", format }, cause); }); - }, []); + }; useEffect( () => () => { @@ -649,7 +633,7 @@ function MarkdownDetails({ const [isOpen, setIsOpen] = useState(open); const searching = use(MarkdownFindContext); const expanded = isOpen; - const revealForFind = useCallback(() => setIsOpen(true), []); + const revealForFind = () => setIsOpen(true); // Base UI only listens for `beforematch` on a panel mounted at its first // render; a closed panel mounts later, when find starts, so listen here. const revealRef = findRevealRef(revealForFind); @@ -825,7 +809,7 @@ function MarkdownCodeBlock({ // make the rendered command differ from what the terminal would receive. !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); - const handleCopy = useCallback(() => { + const handleCopy = () => { if (typeof navigator === "undefined" || navigator.clipboard == null) { return; } @@ -851,7 +835,7 @@ function MarkdownCodeBlock({ cause, ); }); - }, [code, fenceTitle, language]); + }; useEffect( () => () => { @@ -967,7 +951,7 @@ function MarkdownMermaidCodeBlock({ }) { const [showCode, setShowCode] = useState(false); const searching = use(MarkdownFindContext); - const revealSource = useCallback(() => setShowCode(true), []); + const revealSource = () => setShowCode(true); const sourceRevealRef = findRevealRef(revealSource); const showDiagram = !showCode && !isStreaming && code.trim().length > 0; const toggleLabel = showCode ? "Show diagram" : "Show code"; @@ -1086,12 +1070,10 @@ function UncachedShikiCodeBlock({ preserveLines, }: UncachedShikiCodeBlockProps) { const highlighter = use(getSyntaxHighlighterPromise(language)); - const incrementalHighlight = useMemo( - () => - preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, - [highlighter, preserveLines, language, themeName], - ); - const highlighted = useMemo(() => { + const incrementalHighlight = preserveLines + ? createIncrementalHighlightedDocument(highlighter, language, themeName) + : null; + const highlighted = (() => { try { if (incrementalHighlight) return incrementalHighlight(code); return preserveLines @@ -1106,7 +1088,7 @@ function UncachedShikiCodeBlock({ ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } - }, [code, highlighter, incrementalHighlight, language, preserveLines, themeName]); + })(); useEffect(() => { if (!isStreaming) { @@ -1165,23 +1147,22 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; const failedFaviconHosts = new Set(); -function brandLinkIcon(host: string): typeof GitHubIcon | null { +function isGitHubHost(host: string): boolean { const hostname = host.toLowerCase(); - if (hostname === "github.com" || hostname.endsWith(".github.com")) return GitHubIcon; - return null; + return hostname === "github.com" || hostname.endsWith(".github.com"); } const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); - const BrandIcon = brandLinkIcon(host); - const faviconUrl = BrandIcon ? null : faviconUrlForOrigin(`https://${host}`); + const brandIcon = isGitHubHost(host); + const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); return ( - {BrandIcon ? ( - + {brandIcon ? ( + ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( @@ -1361,23 +1342,20 @@ function ChatMarkdownImage(props: { const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); const onDecoded = props.onDecoded; // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = useCallback( - (image: HTMLImageElement | null) => { - if (!image) return; - if (image.complete && image.naturalWidth > 0) { - setLoadedSrc(image.currentSrc || image.src); - onDecoded?.(image); - } - markdownImageItems.set(image, { - src, - name: props.alt.trim() || "image", - actionsSource: props.actionsSource, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - ...props.helperSource, - }); - }, - [onDecoded, props.actionsSource, props.alt, props.helperSource, props.originalUrl, src], - ); + const markLoadedIfComplete = (image: HTMLImageElement | null) => { + if (!image) return; + if (image.complete && image.naturalWidth > 0) { + setLoadedSrc(image.currentSrc || image.src); + onDecoded?.(image); + } + markdownImageItems.set(image, { + src, + name: props.alt.trim() || "image", + actionsSource: props.actionsSource, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + ...props.helperSource, + }); + }; const imageEvents = (loadingSrc: string) => ({ onLoad: (event: { currentTarget: HTMLImageElement }) => { onDecoded?.(event.currentTarget); @@ -1598,13 +1576,13 @@ function ProjectMarkdownImage( const expand = props.onImageExpand; // The last decoded size keeps the box while evicted bytes are read again. const [decodedSize, setDecodedSize] = useState<{ width: number; height: number }>(); - const rememberDecodedSize = useCallback((decoded: HTMLImageElement) => { + const rememberDecodedSize = (decoded: HTMLImageElement) => { const { naturalWidth: width, naturalHeight: height } = decoded; if (width > 0 && height > 0) setDecodedSize((previous) => previous?.width === width && previous.height === height ? previous : { width, height }, ); - }, []); + }; const knownSize = (image?.status === "loaded" ? image.dimensions : undefined) ?? decodedSize; const maxHeightRem = props.maxHeightRem ?? 30; const style = @@ -1952,7 +1930,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onReveal, revealLabel, }: MarkdownFileLinkProps) { - const handleOpenInEditor = useCallback(() => { + const handleOpenInEditor = () => { if (!onOpen) { return; } @@ -1988,9 +1966,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } })(); - }, [onOpen, targetPath]); + }; - const handleOpenInFilePreview = useCallback(() => { + const handleOpenInFilePreview = () => { if (threadRef && panelPath) { onOpenInPanel(panelPath, line); return; @@ -2000,9 +1978,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } handleOpenInEditor(); - }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]); + }; - const handleOpenInBrowser = useCallback(() => { + const handleOpenInBrowser = () => { if (!onOpenInBrowser) { return; } @@ -2038,9 +2016,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } })(); - }, [onOpenInBrowser, targetPath]); + }; - const handleRevealInFileManager = useCallback(() => { + const handleRevealInFileManager = () => { if (!onReveal) { return; } @@ -2076,130 +2054,108 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } })(); - }, [onReveal, targetPath]); + }; + + const handleCopy = (value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: "Clipboard API unavailable.", + }), + ); + return; + } - const handleCopy = useCallback( - (value: string, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + reportMarkdownActionFailure( + { operation: "copy-file-path", target: targetPath, copyTarget: title }, + error, + ); toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", + description: error instanceof Error ? error.message : "An error occurred.", }), ); - return; - } - - void navigator.clipboard.writeText(value).then( - () => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: value, - }); - }, - (error) => { - reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, - error, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }, - [targetPath], - ); + }, + ); + }; - const showFileContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; + const showFileContextMenu = async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; - try { - const clicked = await api.contextMenu.show( - [ - ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen - ? ([{ id: "open", label: openInEditorMenuLabel ?? "Open in editor" }] as const) - : []), - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, - ] as const, - position, - ); + try { + const clicked = await api.contextMenu.show( + [ + ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), + ...(onOpen + ? ([{ id: "open", label: openInEditorMenuLabel ?? "Open in editor" }] as const) + : []), + ...(onOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), + ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + position, + ); - if (clicked === "preview-media") { - onOpenMedia?.(); - return; - } - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } - if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); - return; - } - if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); - } - } catch (cause) { - reportMarkdownActionFailure( - { operation: "show-file-context-menu", target: targetPath }, - cause, - ); + if (clicked === "preview-media") { + onOpenMedia?.(); + return; } - }, - [ - displayPath, - handleCopy, - handleOpenInBrowser, - handleOpenInEditor, - handleRevealInFileManager, - onOpenInBrowser, - onOpenMedia, - onOpen, - onReveal, - openInEditorMenuLabel, - revealLabel, - targetPath, - ], - ); + if (clicked === "open") { + handleOpenInEditor(); + return; + } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); + return; + } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + } catch (cause) { + reportMarkdownActionFailure( + { operation: "show-file-context-menu", target: targetPath }, + cause, + ); + } + }; - const handleContextMenu = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const position = - event.clientX === 0 && event.clientY === 0 - ? (() => { - const bounds = event.currentTarget.getBoundingClientRect(); - return { x: bounds.left, y: bounds.bottom }; - })() - : { x: event.clientX, y: event.clientY }; - void showFileContextMenu(position); - }, - [showFileContextMenu], - ); + const handleContextMenu = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const position = + event.clientX === 0 && event.clientY === 0 + ? (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })() + : { x: event.clientX, y: event.clientY }; + void showFileContextMenu(position); + }; const canOpenInEditor = onOpen !== undefined; const canOpenInBrowser = onOpenInBrowser !== undefined; @@ -2394,49 +2350,50 @@ function useChatMarkdownState({ const canOperateHost = true; const canOperatePreview = false; // Coder: media resolves to a helper-read item rather than a signed asset URL. - const openMarkdownMedia = useCallback( - (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { - const requestId = ++mediaRequestId.current; - void resolveMarkdownMediaPreview({ - source, - resolvedFilePath, - cwd, - threadRef, - onOpenFile: threadRef - ? (path) => useRightPanelStore.getState().openFile(threadRef, path) - : undefined, - }).then( - (preview) => { - if (preview && mediaRequestId.current === requestId) { - const selected = preview.images[preview.index]; - expandMedia( - selected && selected.type !== "video" && markdownRef.current - ? markdownImageGallery(clickedImage ?? markdownRef.current, selected) - : preview, - ); - } - }, - (error: unknown) => { - if (mediaRequestId.current !== requestId) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Media unavailable", - description: - error instanceof Error - ? error.message - : "The file could not be loaded. It may have been moved or deleted.", - }), + const openMarkdownMedia = ( + source: string, + resolvedFilePath?: string, + clickedImage?: HTMLImageElement | null, + ) => { + const requestId = ++mediaRequestId.current; + void resolveMarkdownMediaPreview({ + source, + resolvedFilePath, + cwd, + threadRef, + onOpenFile: threadRef + ? (path) => useRightPanelStore.getState().openFile(threadRef, path) + : undefined, + }).then( + (preview) => { + if (preview && mediaRequestId.current === requestId) { + const selected = preview.images[preview.index]; + expandMedia( + selected && selected.type !== "video" && markdownRef.current + ? markdownImageGallery(clickedImage ?? markdownRef.current, selected) + : preview, ); - }, - ); - }, - [cwd, expandMedia, threadRef], - ); + } + }, + (error: unknown) => { + if (mediaRequestId.current !== requestId) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Media unavailable", + description: + error instanceof Error + ? error.message + : "The file could not be loaded. It may have been moved or deleted.", + }), + ); + }, + ); + }; const { pullRequestsEnabled } = useAtomValue(markdownServerConfigFieldsAtom(environmentId)); const projects = useProjects(); const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownFileLinkMetaByHref = useMemo(() => { + const markdownFileLinkMetaByHref = (() => { const metaByHref = new Map< string, NonNullable> @@ -2451,8 +2408,8 @@ function useChatMarkdownState({ } } return metaByHref; - }, [cwd, imageBaseDir, text]); - const inlineCodeFileLinkMetaByText = useMemo(() => { + })(); + const inlineCodeFileLinkMetaByText = (() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; @@ -2462,22 +2419,22 @@ function useChatMarkdownState({ } } return metaByText; - }, [cwd, imageBaseDir, text]); - const fileLinkParentSuffixByPath = useMemo(() => { + })(); + const fileLinkParentSuffixByPath = (() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), ]; return buildFileLinkParentSuffixByPath(filePaths); - }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); - const markdownUrlTransform = useCallback((href: string) => { + })(); + const markdownUrlTransform = (href: string) => { if (parseAssistantCitationHref(href)) return href; if (parseComposerContextHref(href)) return href; if (parseThreadLinkHref(href)) return href; if (isWindowsDrivePathHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); - }, []); - const handleCopy = useCallback((event: ReactClipboardEvent) => { + }; + const handleCopy = (event: ReactClipboardEvent) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !event.clipboardData) return; const payload = chatMarkdownClipboardPayload(selection); @@ -2491,194 +2448,143 @@ function useChatMarkdownState({ ? encodeComposerContextClipboardHtml(payload.text, fragment, payload.html) : payload.html, ); - }, []); + }; const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); - const resolveThreadPullRequest = useCallback( - (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { - if ( - threadRef === undefined || - readThreadShell(threadRef) === null || - !pullRequestLinking.canLink(href) - ) - return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; - }, - [pullRequestLinking, threadRef], - ); - const linkedThreadPullRequestFor = useCallback( - (href: string) => { - if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) - return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; - }, - [pullRequestLinking, threadRef], - ); - const updateThreadPullRequestLink = useCallback( - async (href: string, linked: boolean) => { - if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; - if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; - await pullRequestLinking.changeLink(threadRef, href, linked); - }, - [linkedThreadPullRequestFor, pullRequestLinking, threadRef], - ); - const findWorkspaceBasenameMatch = useCallback( - async (workspaceRelativePath: string) => { - if ( - !cwd || - environmentId === null || - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || - !needsWorkspaceBasenameLookup(workspaceRelativePath) - ) { - return null; - } - const result = await searchProjectEntries({ - environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - return result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; - }, - [cwd, environmentId, searchProjectEntries], - ); - const openFileInPanel = useCallback( - (panelPath: string, line: number | undefined) => { - if (!threadRef) return; - // Claimed on every open so a synchronous one supersedes a lookup already - // in flight. - const isLatestLookup = claimWorkspaceBasenameLookup(); - const openAt = (path: string) => - useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { - openAt(panelPath); - return; - } - void (async () => { - const match = await findWorkspaceBasenameMatch(panelPath); - if (!isLatestLookup()) return; - openAt(match ?? panelPath); - })(); - }, - [cwd, findWorkspaceBasenameMatch, threadRef], - ); - const fileLinkChip = useCallback( - (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { - const mediaPath = mediaSource ?? fileLinkMeta.filePath; - const canPreviewMedia = - mediaMimeTypeFromExtension( - fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), - ) !== null; - // Coder: the Files surface opens only project files. Media outside the - // workspace keeps the expanded preview; other host files stay inert. - const panelPath = fileLinkMeta.workspaceRelativePath ?? null; - - return ( - openMarkdownMedia(mediaPath, fileLinkMeta.filePath) - : undefined - } - /> - ); - }, - [ + const resolveThreadPullRequest = ( + href: string, + ): (ThreadPullRequestKey & { readonly url: string }) | null => { + if ( + threadRef === undefined || + readThreadShell(threadRef) === null || + !pullRequestLinking.canLink(href) + ) + return null; + const parsed = parseChangeRequestUrl(href); + return parsed === null ? null : { ...parsed, url: href }; + }; + const linkedThreadPullRequestFor = (href: string) => { + if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) + return null; + const parsed = parseChangeRequestUrl(href); + return parsed === null ? null : { ...parsed, url: href }; + }; + const updateThreadPullRequestLink = async (href: string, linked: boolean) => { + if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; + if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; + await pullRequestLinking.changeLink(threadRef, href, linked); + }; + const findWorkspaceBasenameMatch = async (workspaceRelativePath: string) => { + if ( + !cwd || + environmentId === null || + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || + !needsWorkspaceBasenameLookup(workspaceRelativePath) + ) { + return null; + } + const result = await searchProjectEntries({ environmentId, - fileLinkParentSuffixByPath, - openFileInPanel, - openMarkdownMedia, - resolvedTheme, - threadRef, - ], - ); + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + }; + const openFileInPanel = (panelPath: string, line: number | undefined) => { + if (!threadRef) return; + // Claimed on every open so a synchronous one supersedes a lookup already + // in flight. + const isLatestLookup = claimWorkspaceBasenameLookup(); + const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); + if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + openAt(panelPath); + return; + } + void (async () => { + const match = await findWorkspaceBasenameMatch(panelPath); + if (!isLatestLookup()) return; + openAt(match ?? panelPath); + })(); + }; + const fileLinkChip = ( + fileLinkMeta: MarkdownFileLinkMeta, + copyMarkdown: string, + mediaSource?: string, + ) => { + const mediaPath = mediaSource ?? fileLinkMeta.filePath; + const canPreviewMedia = + mediaMimeTypeFromExtension( + fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), + ) !== null; + // Coder: the Files surface opens only project files. Media outside the + // workspace keeps the expanded preview; other host files stay inert. + const panelPath = fileLinkMeta.workspaceRelativePath ?? null; - const componentState = useMemo( - () => ({ - canOperateHost, - canOperatePreview, - cwd, - diffThemeName, - environmentId, - expandMedia, - fileLinkChip, - renderContextReference, - headingLevelOffset, - imageBaseDir, - inlineCodeFileLinkMetaByText, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - onUseArtifactTemplate, - onRunShellCommand, - openChangeRequestLink, - openDeferredMarkdownLink, - openMarkdownMedia, - projects, - linkedThreadPullRequestFor, - resolveThreadPullRequest, - resolvedTheme, - pullRequestsEnabled, - skills, - threadRef, - updateThreadPullRequestLink, - }), - [ - canOperateHost, - canOperatePreview, - cwd, - diffThemeName, - environmentId, - expandMedia, - fileLinkChip, - renderContextReference, - headingLevelOffset, - imageBaseDir, - inlineCodeFileLinkMetaByText, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - onUseArtifactTemplate, - onRunShellCommand, - openChangeRequestLink, - openDeferredMarkdownLink, - openMarkdownMedia, - projects, - linkedThreadPullRequestFor, - resolveThreadPullRequest, - resolvedTheme, - pullRequestsEnabled, - skills, - threadRef, - updateThreadPullRequestLink, - ], - ); + return ( + openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + : undefined + } + /> + ); + }; + + const componentState = { + canOperateHost, + canOperatePreview, + cwd, + diffThemeName, + environmentId, + expandMedia, + fileLinkChip, + renderContextReference, + headingLevelOffset, + imageBaseDir, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + onUseArtifactTemplate, + onRunShellCommand, + openChangeRequestLink, + openDeferredMarkdownLink, + openMarkdownMedia, + projects, + linkedThreadPullRequestFor, + resolveThreadPullRequest, + resolvedTheme, + pullRequestsEnabled, + skills, + threadRef, + updateThreadPullRequestLink, + }; return { componentState, handleCopy, @@ -3250,14 +3156,11 @@ function ChatMarkdown({ props.isStreaming === true && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); - const remarkPlugins = useMemo( - () => [ - ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), - ...extraRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ], - [extraRemarkPlugins, incrementalParsing, lineBreaks], - ); + const remarkPlugins = [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...extraRemarkPlugins, + ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), + ]; return (
{localMediaPreview ? ( - + ) : null}
); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0f7328af2410..55f2cd8c36cc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -702,18 +702,19 @@ async function rewindToMessage(input: { ) { throw new Error("Make room for this message's attachments in the composer before rewinding."); } - // Coder: sent images are read back through the helper's bounded chunk reads; upstream - // fetches attachments from asset URLs. File attachments live only in the workspace and - // are not downloaded, so only images return to the draft. - const images: ComposerImageAttachment[] = []; - for (const image of imageReferences) { - const blob = await readScreenshotBlob( - image, - input.readImageChunk, - new AbortController().signal, - ); - const file = new File([blob], image.name, { type: image.mimeType }); - images.push({ + // Coder: sent images are read back through the helper's bounded chunk reads, all at once + // as upstream fetches its asset URLs; a rewind cannot be cancelled, so nothing aborts them. + // File attachments live only in the workspace and are not downloaded, so only images + // return to the draft. Preview URLs are created only once every read has succeeded. + const signal = new AbortController().signal; + const files = await Promise.all( + imageReferences.map(async (image) => { + const blob = await readScreenshotBlob(image, input.readImageChunk, signal); + return new File([blob], image.name, { type: image.mimeType }); + }), + ); + const images = files.map( + (file): ComposerImageAttachment => ({ type: "image", id: randomUUID(), name: file.name, @@ -721,8 +722,8 @@ async function rewindToMessage(input: { sizeBytes: file.size, previewUrl: URL.createObjectURL(file), file, - }); - } + }), + ); const commandId = CommandId.make(randomUUID()); await waitForRevertedMessage( input.routeThreadRef, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index d8b2ba13b90c..10bba75a0b21 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -34,7 +34,7 @@ import { useFilesystemReadAccess } from "~/state/filesystem"; import { FolderTree, WrapTextIcon } from "lucide-react"; import { Code2, Eye, Table2 } from "lucide"; import * as Schema from "effect/Schema"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { projectMediaReference } from "~/components/chat/projectMediaReference"; import { useProjectImages } from "~/components/chat/useProjectImages"; @@ -316,164 +316,161 @@ function useFileLineReveal( ): FilePostRender { const [revealStatesByPath] = useState(() => new Map()); - return useCallback( - (fileContainer, instance, phase) => { - if (relativePath === null) return; + return (fileContainer, instance, phase) => { + if (relativePath === null) return; - const existingState = revealStatesByPath.get(relativePath); - const state: FileRevealState = existingState ?? { - frameId: null, - cancelGuard: null, - handledRequestId: null, - latestRequestId: null, - }; - if (!existingState) revealStatesByPath.set(relativePath, state); - - const cancelPendingReveal = () => { - if (state.frameId !== null) { - cancelAnimationFrame(state.frameId); - state.frameId = null; - } - state.cancelGuard?.(); - }; + const existingState = revealStatesByPath.get(relativePath); + const state: FileRevealState = existingState ?? { + frameId: null, + cancelGuard: null, + handledRequestId: null, + latestRequestId: null, + }; + if (!existingState) revealStatesByPath.set(relativePath, state); - if (phase === "unmount") { - cancelPendingReveal(); - return; + const cancelPendingReveal = () => { + if (state.frameId !== null) { + cancelAnimationFrame(state.frameId); + state.frameId = null; } + state.cancelGuard?.(); + }; - const contents = instance.file?.contents; - const targetLine = - revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); - updateFileLinkReveal(fileContainer, targetLine); + if (phase === "unmount") { + cancelPendingReveal(); + return; + } - if (!(instance instanceof VirtualizedFile)) return; + const contents = instance.file?.contents; + const targetLine = + revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); + updateFileLinkReveal(fileContainer, targetLine); - if (state.latestRequestId !== revealRequestId) { - cancelPendingReveal(); - state.latestRequestId = revealRequestId; - state.handledRequestId = null; - } + if (!(instance instanceof VirtualizedFile)) return; - if (revealLine === null) { - fileContainer.style.minHeight = ""; - return; - } + if (state.latestRequestId !== revealRequestId) { + cancelPendingReveal(); + state.latestRequestId = revealRequestId; + state.handledRequestId = null; + } - const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); - if (!scrollContainer) return; - fileContainer.style.minHeight = `${Math.ceil( - Math.max(instance.height, scrollContainer.clientHeight), - )}px`; + if (revealLine === null) { + fileContainer.style.minHeight = ""; + return; + } - if (state.handledRequestId === revealRequestId || state.frameId !== null) { - return; - } + const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); + if (!scrollContainer) return; + fileContainer.style.minHeight = `${Math.ceil( + Math.max(instance.height, scrollContainer.clientHeight), + )}px`; - const resolveScrollTarget = (line: number): number | null => { - const linePosition = instance.getLinePosition(line); - if (!linePosition) return null; + if (state.handledRequestId === revealRequestId || state.frameId !== null) { + return; + } - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const fileTop = - scrollContainer.scrollTop + - fileContainer.getBoundingClientRect().top - - scrollContainerRect.top; - const root = fileContainer.shadowRoot ?? fileContainer; - const renderedLineElement = root.querySelector(`[data-line="${line}"]`); - const renderedLineRect = renderedLineElement?.getBoundingClientRect(); + const resolveScrollTarget = (line: number): number | null => { + const linePosition = instance.getLinePosition(line); + if (!linePosition) return null; - return resolveCenteredFileLineScrollTop({ - scrollTop: scrollContainer.scrollTop, - scrollHeight: scrollContainer.scrollHeight, - viewportTop: scrollContainerRect.top, - viewportHeight: scrollContainer.clientHeight, - fileTop, - estimatedLine: linePosition, - ...(renderedLineRect && renderedLineRect.height > 0 - ? { - renderedLine: { - top: renderedLineRect.top, - height: renderedLineRect.height, - }, - } - : {}), - }); - }; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const fileTop = + scrollContainer.scrollTop + + fileContainer.getBoundingClientRect().top - + scrollContainerRect.top; + const root = fileContainer.shadowRoot ?? fileContainer; + const renderedLineElement = root.querySelector(`[data-line="${line}"]`); + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); + + return resolveCenteredFileLineScrollTop({ + scrollTop: scrollContainer.scrollTop, + scrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollContainerRect.top, + viewportHeight: scrollContainer.clientHeight, + fileTop, + estimatedLine: linePosition, + ...(renderedLineRect && renderedLineRect.height > 0 + ? { + renderedLine: { + top: renderedLineRect.top, + height: renderedLineRect.height, + }, + } + : {}), + }); + }; - const guardScrollTarget = (line: number) => { - let framesLeft = REVEAL_GUARD_FRAMES; - let guardFrameId: number | null = null; - const cancelGuard = () => { - if (guardFrameId !== null) { - cancelAnimationFrame(guardFrameId); - guardFrameId = null; - } - scrollContainer.removeEventListener("wheel", cancelGuard); - scrollContainer.removeEventListener("touchstart", cancelGuard); - scrollContainer.removeEventListener("pointerdown", cancelGuard, true); - window.removeEventListener("keydown", cancelGuard, true); - if (state.cancelGuard === cancelGuard) state.cancelGuard = null; - }; - scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); - scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); - // Pierre stops gutter pointer events from bubbling. Listen in capture - // so starting a comment cancels the reveal guard before the row expands. - scrollContainer.addEventListener("pointerdown", cancelGuard, { - passive: true, - capture: true, - }); - window.addEventListener("keydown", cancelGuard, true); - const holdTarget = () => { + const guardScrollTarget = (line: number) => { + let framesLeft = REVEAL_GUARD_FRAMES; + let guardFrameId: number | null = null; + const cancelGuard = () => { + if (guardFrameId !== null) { + cancelAnimationFrame(guardFrameId); guardFrameId = null; - framesLeft -= 1; - if (framesLeft <= 0 || !scrollContainer.isConnected) { - cancelGuard(); - return; - } - const targetTop = resolveScrollTarget(line); - if ( - targetTop !== null && - Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX - ) { - scrollContainer.scrollTop = targetTop; - } - guardFrameId = requestAnimationFrame(holdTarget); - }; + } + scrollContainer.removeEventListener("wheel", cancelGuard); + scrollContainer.removeEventListener("touchstart", cancelGuard); + scrollContainer.removeEventListener("pointerdown", cancelGuard, true); + window.removeEventListener("keydown", cancelGuard, true); + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; + }; + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); + // Pierre stops gutter pointer events from bubbling. Listen in capture + // so starting a comment cancels the reveal guard before the row expands. + scrollContainer.addEventListener("pointerdown", cancelGuard, { + passive: true, + capture: true, + }); + window.addEventListener("keydown", cancelGuard, true); + const holdTarget = () => { + guardFrameId = null; + framesLeft -= 1; + if (framesLeft <= 0 || !scrollContainer.isConnected) { + cancelGuard(); + return; + } + const targetTop = resolveScrollTarget(line); + if ( + targetTop !== null && + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX + ) { + scrollContainer.scrollTop = targetTop; + } guardFrameId = requestAnimationFrame(holdTarget); - state.cancelGuard = cancelGuard; }; + guardFrameId = requestAnimationFrame(holdTarget); + state.cancelGuard = cancelGuard; + }; - const scheduleReveal = (attempt: number) => { - state.frameId = requestAnimationFrame(() => { - state.frameId = null; - if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { - return; - } + const scheduleReveal = (attempt: number) => { + state.frameId = requestAnimationFrame(() => { + state.frameId = null; + if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { + return; + } - // Contents and line metrics can lag the first post-render on fresh - // mounts; clamping against missing contents would scroll to line 1 - // and wrongly mark the request handled. - const currentContents = instance.file?.contents; - const line = - currentContents === undefined ? null : clampFileLine(currentContents, revealLine); - const targetTop = line === null ? null : resolveScrollTarget(line); - if (line === null || targetTop === null) { - if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); - return; - } - updateFileLinkReveal(fileContainer, line); + // Contents and line metrics can lag the first post-render on fresh + // mounts; clamping against missing contents would scroll to line 1 + // and wrongly mark the request handled. + const currentContents = instance.file?.contents; + const line = + currentContents === undefined ? null : clampFileLine(currentContents, revealLine); + const targetTop = line === null ? null : resolveScrollTarget(line); + if (line === null || targetTop === null) { + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); + return; + } + updateFileLinkReveal(fileContainer, line); - scrollContainer.scrollTop = targetTop; - state.handledRequestId = revealRequestId; - guardScrollTarget(line); - }); - }; + scrollContainer.scrollTop = targetTop; + state.handledRequestId = revealRequestId; + guardScrollTarget(line); + }); + }; - scheduleReveal(0); - }, - [revealStatesByPath, relativePath, revealLine, revealRequestId], - ); + scheduleReveal(0); + }; } const createFileEditor: EditorFactory = ( @@ -517,7 +514,7 @@ function needsWorkerHighlight(workerPool: WorkerPoolManager | undefined, file: F function useEditableAfterHighlight(file: FileContents) { const workerPool = useWorkerPool(); const [highlightedFile, setHighlightedFile] = useState(null); - const needsHighlight = useMemo(() => needsWorkerHighlight(workerPool, file), [file, workerPool]); + const needsHighlight = needsWorkerHighlight(workerPool, file); const ready = !needsHighlight || highlightedFile === file; useEffect(() => { @@ -525,15 +522,12 @@ function useEditableAfterHighlight(file: FileContents) { workerPool.primeFileHighlightCache(file).catch(() => setHighlightedFile(file)); }, [file, ready, workerPool]); - const onPostRender = useCallback( - (renderedFile: FileContents | undefined, phase: PostRenderPhase) => { - if (ready || phase === "unmount" || renderedFile?.cacheKey !== file.cacheKey) return; - // The pool caches a result just before the instance renders it, so a - // render that sees the cache has painted highlighted rows. - if (workerPool?.getFileResultCache(file) !== undefined) setHighlightedFile(file); - }, - [file, ready, workerPool], - ); + const onPostRender = (renderedFile: FileContents | undefined, phase: PostRenderPhase) => { + if (ready || phase === "unmount" || renderedFile?.cacheKey !== file.cacheKey) return; + // The pool caches a result just before the instance renders it, so a + // render that sees the cache has painted highlighted rows. + if (workerPool?.getFileResultCache(file) !== undefined) setHighlightedFile(file); + }; return { ready, onPostRender }; } @@ -580,12 +574,9 @@ function EditableFileSurface({ const [selectionOverride, setSelectionOverride] = useState(null); const selectedRange = selectionOverride?.revealRequestId === revealRequestId ? selectionOverride.range : null; - const setSelectedRange = useCallback( - (range: SelectedLineRange | null) => { - setSelectionOverride({ revealRequestId, range }); - }, - [revealRequestId], - ); + const setSelectedRange = (range: SelectedLineRange | null) => { + setSelectionOverride({ revealRequestId, range }); + }; const surfaceRef = useRef(null); const selectionFrameRef = useRef(null); const saveCoordinator = useFileSaveCoordinator({ @@ -615,80 +606,30 @@ function EditableFileSurface({ const { ready: editable, onPostRender: onEditablePostRender } = useEditableAfterHighlight(externalFile); const editorRef = useRef | null>(null); - const editorOptions = useMemo>( - () => ({ - onAttach: (editor) => { - editorRef.current = editor; - }, - onComplete: () => { - editorRef.current = null; - }, - }), - [], - ); - - const handleEditChange = useCallback( - ({ - file, - lineAnnotations: nextLineAnnotations, - }: EditorChangeEvent<"file", FileCommentAnnotationGroup, undefined>) => { - if (file.contents === getProjectFileContents(environmentId, owner, cwd, relativePath)) return; - setEditedContents(file.contents); - setProjectFileQueryData(environmentId, cwd, relativePath, file.contents, revisionRef.current); - saveCoordinator.change(file.contents); - if (!nextLineAnnotations) return; - const remapped = remapFileCommentAnnotations(nextLineAnnotations); - // The editor hands back the array it was given until an edit moves an annotation. - setLineAnnotations((current) => (current === nextLineAnnotations ? current : remapped)); - for (const annotation of remapped) { - for (const entry of annotation.metadata.entries) { - if (entry.kind !== "comment") continue; - addReviewComment( - composerDraftTarget, - buildFileReviewComment({ - id: entry.id, - filePath: relativePath, - startLine: entry.startLine, - endLine: entry.endLine, - text: entry.text, - contents: file.contents, - }), - ); - } - } + const editorOptions: EditorOptions<"file", FileCommentAnnotationGroup, undefined> = { + onAttach: (editor) => { + editorRef.current = editor; }, - [ - addReviewComment, - composerDraftTarget, - cwd, - environmentId, - owner, - relativePath, - saveCoordinator, - ], - ); - - const removeAnnotationEntry = useCallback( - (entryId: string) => { - setSelectedRange(null); - removeReviewComment(composerDraftTarget, entryId); - setLineAnnotations((current) => { - return current.flatMap((annotation) => { - const entries = annotation.metadata.entries.filter((entry) => entry.id !== entryId); - return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; - }); - }); + onComplete: () => { + editorRef.current = null; }, - [composerDraftTarget, removeReviewComment, setSelectedRange], - ); + }; - const submitAnnotationEntry = useCallback( - (entryId: string, text: string) => { - setSelectedRange(null); - const entry = lineAnnotations - .flatMap((annotation) => annotation.metadata.entries) - .find((candidate) => candidate.id === entryId); - if (entry) { + const handleEditChange = ({ + file, + lineAnnotations: nextLineAnnotations, + }: EditorChangeEvent<"file", FileCommentAnnotationGroup, undefined>) => { + if (file.contents === getProjectFileContents(environmentId, owner, cwd, relativePath)) return; + setEditedContents(file.contents); + setProjectFileQueryData(environmentId, cwd, relativePath, file.contents, revisionRef.current); + saveCoordinator.change(file.contents); + if (!nextLineAnnotations) return; + const remapped = remapFileCommentAnnotations(nextLineAnnotations); + // The editor hands back the array it was given until an edit moves an annotation. + setLineAnnotations((current) => (current === nextLineAnnotations ? current : remapped)); + for (const annotation of remapped) { + for (const entry of annotation.metadata.entries) { + if (entry.kind !== "comment") continue; addReviewComment( composerDraftTarget, buildFileReviewComment({ @@ -696,35 +637,58 @@ function EditableFileSurface({ filePath: relativePath, startLine: entry.startLine, endLine: entry.endLine, - text, - contents, + text: entry.text, + contents: file.contents, }), ); } - setLineAnnotations((current) => - current.map((annotation) => ({ - ...annotation, - metadata: { - entries: annotation.metadata.entries.map((annotationEntry) => - annotationEntry.id === entryId - ? { ...annotationEntry, kind: "comment", text } - : annotationEntry, - ), - }, - })), + } + }; + + const removeAnnotationEntry = (entryId: string) => { + setSelectedRange(null); + removeReviewComment(composerDraftTarget, entryId); + setLineAnnotations((current) => { + return current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.id !== entryId); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; + }); + }); + }; + + const submitAnnotationEntry = (entryId: string, text: string) => { + setSelectedRange(null); + const entry = lineAnnotations + .flatMap((annotation) => annotation.metadata.entries) + .find((candidate) => candidate.id === entryId); + if (entry) { + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text, + contents, + }), ); - }, - [ - addReviewComment, - composerDraftTarget, - contents, - lineAnnotations, - relativePath, - setSelectedRange, - ], - ); + } + setLineAnnotations((current) => + current.map((annotation) => ({ + ...annotation, + metadata: { + entries: annotation.metadata.entries.map((annotationEntry) => + annotationEntry.id === entryId + ? { ...annotationEntry, kind: "comment", text } + : annotationEntry, + ), + }, + })), + ); + }; - const beginComment = useCallback((range: SelectedLineRange) => { + const beginComment = (range: SelectedLineRange) => { editorRef.current?.setSelections([]); editorRef.current?.blur(); const { startLine, endLine } = normalizeFileCommentRange(range); @@ -761,7 +725,7 @@ function EditableFileSurface({ : annotation, ); }); - }, []); + }; const hasOpenCommentForm = lineAnnotations.some((annotation) => annotation.metadata.entries.some((entry) => entry.kind === "draft"), ); @@ -775,35 +739,29 @@ function EditableFileSurface({ onDismiss: () => setSelectedRange(null), }); }, [hasOpenCommentForm, setSelectedRange]); - const handleLineSelectionEnd = useCallback( - (range: SelectedLineRange | null) => { - setSelectedRange(range); - if (range) { - beginComment(range); - } - }, - [beginComment, setSelectedRange], - ); + const handleLineSelectionEnd = (range: SelectedLineRange | null) => { + setSelectedRange(range); + if (range) { + beginComment(range); + } + }; - const handlePostRender = useCallback( - (fileContainer, instance, phase) => { - onPostRender(fileContainer, instance, phase); - onEditablePostRender(instance.file, phase); + const handlePostRender: FilePostRender = (fileContainer, instance, phase) => { + onPostRender(fileContainer, instance, phase); + onEditablePostRender(instance.file, phase); - if (selectionFrameRef.current !== null) { - cancelAnimationFrame(selectionFrameRef.current); - selectionFrameRef.current = null; - } - if (phase === "unmount") return; + if (selectionFrameRef.current !== null) { + cancelAnimationFrame(selectionFrameRef.current); + selectionFrameRef.current = null; + } + if (phase === "unmount") return; - selectionFrameRef.current = requestAnimationFrame(() => { - selectionFrameRef.current = null; - if (!fileContainer.isConnected) return; - instance.setSelectedLines(selectedRange, { notify: false }); - }); - }, - [onEditablePostRender, onPostRender, selectedRange], - ); + selectionFrameRef.current = requestAnimationFrame(() => { + selectionFrameRef.current = null; + if (!fileContainer.isConnected) return; + instance.setSelectedLines(selectedRange, { notify: false }); + }); + }; return ( @@ -962,10 +920,9 @@ export default function FilePreviewPanel({ const draft = typeof composerDraftTarget === "string"; // Coder: every Files request names its owner. The server does not know a draft yet, so a draft // names its project and the helper verifies the root against that project instead. - const owner = useMemo( - () => (draft ? { draftProjectId: projectId } : { threadId: threadRef.threadId }), - [draft, projectId, threadRef.threadId], - ); + const owner: ProjectFilesOwner = draft + ? { draftProjectId: projectId } + : { threadId: threadRef.threadId }; const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); @@ -1095,6 +1052,7 @@ export default function FilePreviewPanel({ breadcrumbRef.current ?.querySelector("[data-current-file-crumb='true']") ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new path or trail re-renders the crumbs, so the current one scrolls back into view. }, [breadcrumbPath, breadcrumbTrail]); const toggleExplorer = () => { @@ -1113,21 +1071,18 @@ export default function FilePreviewPanel({ // workspace) leaves the edit pending; offer to discard it and reload the file. const [saveFailedPath, setSaveFailedPath] = useState(null); const [reloadCount, setReloadCount] = useState(0); - const handlePendingChange = useCallback( - (path: string, pending: boolean) => { - if (pending) setSaveFailedPath((current) => (current === path ? null : current)); - onPendingChange(path, pending); - }, - [onPendingChange], - ); - const reloadFile = useCallback(() => { + const handlePendingChange = (path: string, pending: boolean) => { + if (pending) setSaveFailedPath((current) => (current === path ? null : current)); + onPendingChange(path, pending); + }; + const reloadFile = () => { if (!relativePath) return; clearProjectFileQueryData(environmentId, cwd, relativePath); file.refresh(); onPendingChange(relativePath, false); setSaveFailedPath(null); setReloadCount((current) => current + 1); - }, [cwd, environmentId, file, onPendingChange, relativePath]); + }; if (!canReadFiles) { if (fileAccess.isPending) {