From 9b84cb8097645f14d8b7163ee7a6ba0d608f79d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:52:55 -0700 Subject: [PATCH] refactor(web): chat markdown images and videos live in their own module ChatMarkdownImage, ChatMarkdownVideo, ChatMarkdownAssetImage, their fallbacks and sizing helpers move to chat/ChatMarkdownMedia.tsx. Timeline and inspector import the asset image from there. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 466 +----------------- .../src/components/chat/ChatMarkdownMedia.tsx | 466 ++++++++++++++++++ .../components/chat/TimelineWorkEntryRow.tsx | 3 +- .../src/components/chat/V2ItemInspector.tsx | 3 +- 4 files changed, 482 insertions(+), 456 deletions(-) create mode 100644 apps/web/src/components/chat/ChatMarkdownMedia.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4cb89db2ab87..f0793fbc07db 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -38,7 +38,6 @@ import { import { AssetWorkspaceAssetNotFoundError, AuthPreviewOperateScope, - type AssetResource, type EditorId, type EnvironmentId, type ServerConfig, @@ -60,8 +59,7 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -69,14 +67,10 @@ import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import React, { Suspense, - type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, use, memo, - useEffect, useLayoutEffect, useRef, useState, @@ -108,9 +102,8 @@ import { type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; -import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; -import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; -import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { markdownImageGallery } from "./chat/markdownImageGallery"; +import { type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { revealInFileExplorerLabelForKind, @@ -149,7 +142,6 @@ import { import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; @@ -161,7 +153,6 @@ import { readEnvironmentScope, readPreparedConnection, useEnvironmentScope, - usePreparedConnection, } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; @@ -198,6 +189,15 @@ import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBl import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; import { MarkdownTable } from "./chat/MarkdownTable"; import { MarkdownDetails } from "./chat/MarkdownDetails"; +import { + authoredImageSizeStyle, + ChatMarkdownAssetImage, + ChatMarkdownImage, + ChatMarkdownImageFallback, + ChatMarkdownVideo, + MarkdownLinkContext, + markdownImageCopy, +} from "./chat/ChatMarkdownMedia"; import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; import { MarkdownFileLink } from "./chat/MarkdownFileLink"; import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; @@ -380,448 +380,6 @@ function normalizeMarkdownLinkHrefKey(href: string): string { : rewrittenHref; } -const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; -const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( - "max-h-[30rem]", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, -); -const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; -const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( - "h-auto w-auto object-contain", - CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, -); - -function markdownImageCopy(alt: string, src: string, title: string | undefined): string { - const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); - const titleSuffix = - title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; -} - -/** - * `maxHeightRem` folds a height cap into the width bound: `max-height` alone - * would not feed back through `aspect-ratio` once `width` is definite, so a - * tall image would keep a box wider than the picture it draws. - */ -function authoredImageSizeStyle( - width: string | number | undefined, - height: string | number | undefined, - maxHeightRem = 30, -): CSSProperties | undefined { - const parsedWidth = Number(width); - const parsedHeight = Number(height); - const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; - const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; - if (hasWidth && hasHeight) { - return { - width: parsedWidth, - height: "auto", - aspectRatio: `${parsedWidth} / ${parsedHeight}`, - maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, - }; - } - if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; - if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; - return undefined; -} - -const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); -const MarkdownLinkContext = React.createContext(false); - -function expandableMarkdownImageProps( - onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - alt: string, -) { - if (!onImageExpand) return {}; - const previewName = alt.trim() || "image"; - const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { - if (event.currentTarget.closest("a")) return; - event.preventDefault(); - event.stopPropagation(); - const item = markdownImageItems.get(event.currentTarget); - if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); - }; - return { - role: "button" as const, - tabIndex: 0, - "aria-label": `Preview ${previewName}`, - onClick: expand, - onKeyDown: (event: ReactKeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") expand(event); - }, - }; -} - -function ChatMarkdownMediaUnavailableLabel(props: { - readonly alt: string; - readonly kind?: "image" | "video" | undefined; - readonly detail?: string | undefined; -}) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; - return ( - - - {[label, props.alt, props.detail].filter(Boolean).join(" · ")} - - ); -} - -function ChatMarkdownImageFallback(props: { - readonly alt: string; - readonly copyMarkdown?: string | undefined; - readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource | undefined; - readonly detail?: string | undefined; -}) { - const content = ( - - - - ); - return props.actionsSource ? ( - {content} - ) : ( - content - ); -} - -const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( - "aspect-video w-full overflow-hidden bg-muted/60", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); - -/** - * Callers key this on the file's identity, not its URL: a re-signed URL for - * the same file keeps the decoded image on screen while the new bytes arrive, - * and a different file starts from the slot again. - */ -function ChatMarkdownImage(props: { - readonly src: string | null; - readonly sourceFailed?: boolean | undefined; - readonly unavailableDetail?: string | undefined; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly standalone: boolean; - readonly className?: string | undefined; - readonly style?: CSSProperties | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly actionsSource: MediaActionSource; - readonly originalUrl?: string | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const [loadedSrc, setLoadedSrc] = useState(null); - const [failedSrc, setFailedSrc] = useState(null); - const src = props.src ?? loadedSrc; - const failed = props.sourceFailed === true || (src !== null && failedSrc === src); - const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); - // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = (image: HTMLImageElement | null) => { - if (!image) return; - if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - markdownImageItems.set(image, { - src, - name: props.alt.trim() || "image", - actionsSource: props.actionsSource, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - }); - }; - const imageEvents = (loadingSrc: string) => ({ - onLoad: () => { - setLoadedSrc(loadingSrc); - setFailedSrc(null); - }, - onError: () => { - setFailedSrc(loadingSrc); - setLoadedSrc(null); - }, - }); - - if (settled) { - return ( - - {props.alt} - - ); - } - if (!props.standalone) { - return failed ? ( - - ) : ( - - ); - } - return ( - - - {failed ? ( - - - - ) : src !== null ? ( - {props.alt} - ) : null} - - - ); -} - -function ChatMarkdownVideo(props: { - readonly src: string | null; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly originalUrl?: string | undefined; - readonly sourceFailed?: boolean | undefined; - readonly style?: CSSProperties | undefined; - readonly mediaIdentity?: string | undefined; - readonly actionsSource?: MediaActionSource | undefined; - readonly onRetry?: (() => Promise) | undefined; -}) { - return ( - - ); -} - -export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { - readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { - readonly _tag: - | "attachment" - | "workspace-file" - | "media-file" - | "github-media" - | "tool-output-image"; - } - >; - readonly kind?: "image" | "video"; - readonly alt: string; - readonly copyMarkdown?: string; - readonly srcFragment?: string; - readonly standalone?: boolean | undefined; - readonly maxHeightRem?: number | undefined; - readonly style?: CSSProperties | undefined; - readonly className?: string | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly originalUrl?: string | undefined; - readonly framed?: boolean | undefined; - readonly fallbackSrc?: string | undefined; - readonly workspaceRoot?: string | undefined; - readonly workspaceMutationId?: string | null | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; - const resource = props.resource; - const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); - const handledRefreshToken = useRef(undefined); - const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; - useEffect(() => { - if (!connectionReady) { - handledRefreshToken.current = null; - return; - } - if ( - props.workspaceMutationId === undefined || - props.kind === "video" || - (resource._tag !== "media-file" && resource._tag !== "workspace-file") || - handledRefreshToken.current === refreshToken - ) { - return; - } - if (assetLoading) { - // Only the initial mount shares a pending request; edits and reconnects need a new one. - if (handledRefreshToken.current === undefined) { - handledRefreshToken.current = refreshToken; - } - return; - } - handledRefreshToken.current = refreshToken; - void refreshAssetUrl().catch(() => undefined); - }, [ - assetLoading, - connectionReady, - props.kind, - props.workspaceMutationId, - refreshAssetUrl, - refreshToken, - resource._tag, - ]); - const path = - resource._tag === "media-file" - ? resource.path - : resource._tag === "workspace-file" && props.workspaceRoot - ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` - : undefined; - const reference = path - ? mediaFileReference(path, props.workspaceRoot) - : props.originalUrl - ? mediaUrlReference(props.originalUrl) - : undefined; - const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; - const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; - const src = - assetUrl._tag === "Success" - ? assetUrl.url + (props.srcFragment ?? "") - : fallbackSrc === undefined - ? null - : fallbackSrc + (props.srcFragment ?? ""); - const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; - const maxHeightRem = props.maxHeightRem ?? 30; - const style = - props.style ?? - (knownSize - ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) - : maxHeightRem !== 30 - ? { maxHeight: `${maxHeightRem}rem` } - : undefined); - const actionsSource: MediaActionSource = { - kind: props.kind ?? "image", - name: props.alt || (props.kind ?? "image"), - src, - ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } - : {}), - ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? { - onOpenFile: () => - useRightPanelStore - .getState() - .openFile( - { environmentId: props.environmentId, threadId: resource.threadId }, - relativePath, - ), - } - : {}), - }; - - if (props.kind === "video") { - return ( - - ); - } - - return ( - - ); -}); - // Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx new file mode 100644 index 000000000000..f0601234628e --- /dev/null +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -0,0 +1,466 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { TriangleAlertIcon } from "lucide-react"; +import { + createContext, + memo, + useEffect, + useRef, + useState, + type ComponentProps, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; + +import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; +import { cn } from "../../lib/utils"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { usePreparedConnection } from "../../state/session"; +import { MediaActions, type MediaActionSource } from "../media/MediaActions"; +import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; +import type { ExpandedImagePreview } from "./ExpandedImagePreview"; +import { markdownImageGallery, markdownImageItems } from "./markdownImageGallery"; + +const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( + "max-h-[30rem]", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, +); +const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; +const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( + "h-auto w-auto object-contain", + CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, +); + +export function markdownImageCopy(alt: string, src: string, title: string | undefined): string { + const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); + const titleSuffix = + title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + return `![${escapedAlt}](${src}${titleSuffix})`; +} + +/** + * `maxHeightRem` folds a height cap into the width bound: `max-height` alone + * would not feed back through `aspect-ratio` once `width` is definite, so a + * tall image would keep a box wider than the picture it draws. + */ +export function authoredImageSizeStyle( + width: string | number | undefined, + height: string | number | undefined, + maxHeightRem = 30, +): CSSProperties | undefined { + const parsedWidth = Number(width); + const parsedHeight = Number(height); + const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; + const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; + if (hasWidth && hasHeight) { + return { + width: parsedWidth, + height: "auto", + aspectRatio: `${parsedWidth} / ${parsedHeight}`, + maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, + }; + } + if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; + if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; + return undefined; +} + +const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( + CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, +); +export const MarkdownLinkContext = createContext(false); + +function expandableMarkdownImageProps( + onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, + alt: string, +) { + if (!onImageExpand) return {}; + const previewName = alt.trim() || "image"; + const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { + if (event.currentTarget.closest("a")) return; + event.preventDefault(); + event.stopPropagation(); + const item = markdownImageItems.get(event.currentTarget); + if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); + }; + return { + role: "button" as const, + tabIndex: 0, + "aria-label": `Preview ${previewName}`, + onClick: expand, + onKeyDown: (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") expand(event); + }, + }; +} + +function ChatMarkdownMediaUnavailableLabel(props: { + readonly alt: string; + readonly kind?: "image" | "video" | undefined; + readonly detail?: string | undefined; +}) { + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + return ( + + + {[label, props.alt, props.detail].filter(Boolean).join(" · ")} + + ); +} + +export function ChatMarkdownImageFallback(props: { + readonly alt: string; + readonly copyMarkdown?: string | undefined; + readonly kind?: "image" | "video"; + readonly actionsSource?: MediaActionSource | undefined; + readonly detail?: string | undefined; +}) { + const content = ( + + + + ); + return props.actionsSource ? ( + {content} + ) : ( + content + ); +} + +const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( + "aspect-video w-full overflow-hidden bg-muted/60", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, +); + +/** + * Callers key this on the file's identity, not its URL: a re-signed URL for + * the same file keeps the decoded image on screen while the new bytes arrive, + * and a different file starts from the slot again. + */ +export function ChatMarkdownImage(props: { + readonly src: string | null; + readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly standalone: boolean; + readonly className?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + readonly actionsSource: MediaActionSource; + readonly originalUrl?: string | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = props.src ?? loadedSrc; + const failed = props.sourceFailed === true || (src !== null && failedSrc === src); + const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); + // Cached images are complete before `onLoad` can fire. + const markLoadedIfComplete = (image: HTMLImageElement | null) => { + if (!image) return; + if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); + markdownImageItems.set(image, { + src, + name: props.alt.trim() || "image", + actionsSource: props.actionsSource, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + }); + }; + const imageEvents = (loadingSrc: string) => ({ + onLoad: () => { + setLoadedSrc(loadingSrc); + setFailedSrc(null); + }, + onError: () => { + setFailedSrc(loadingSrc); + setLoadedSrc(null); + }, + }); + + if (settled) { + return ( + + {props.alt} + + ); + } + if (!props.standalone) { + return failed ? ( + + ) : ( + + ); + } + return ( + + + {failed ? ( + + + + ) : src !== null ? ( + {props.alt} + ) : null} + + + ); +} + +export function ChatMarkdownVideo(props: { + readonly src: string | null; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly originalUrl?: string | undefined; + readonly sourceFailed?: boolean | undefined; + readonly style?: CSSProperties | undefined; + readonly mediaIdentity?: string | undefined; + readonly actionsSource?: MediaActionSource | undefined; + readonly onRetry?: (() => Promise) | undefined; +}) { + return ( + + ); +} + +export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { + readonly environmentId: EnvironmentId; + readonly resource: Extract< + AssetResource, + { + readonly _tag: + | "attachment" + | "workspace-file" + | "media-file" + | "github-media" + | "tool-output-image"; + } + >; + readonly kind?: "image" | "video"; + readonly alt: string; + readonly copyMarkdown?: string; + readonly srcFragment?: string; + readonly standalone?: boolean | undefined; + readonly maxHeightRem?: number | undefined; + readonly style?: CSSProperties | undefined; + readonly className?: string | undefined; + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + readonly originalUrl?: string | undefined; + readonly framed?: boolean | undefined; + readonly fallbackSrc?: string | undefined; + readonly workspaceRoot?: string | undefined; + readonly workspaceMutationId?: string | null | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); + const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; + const resource = props.resource; + const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); + const handledRefreshToken = useRef(undefined); + const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; + useEffect(() => { + if (!connectionReady) { + handledRefreshToken.current = null; + return; + } + if ( + props.workspaceMutationId === undefined || + props.kind === "video" || + (resource._tag !== "media-file" && resource._tag !== "workspace-file") || + handledRefreshToken.current === refreshToken + ) { + return; + } + if (assetLoading) { + // Only the initial mount shares a pending request; edits and reconnects need a new one. + if (handledRefreshToken.current === undefined) { + handledRefreshToken.current = refreshToken; + } + return; + } + handledRefreshToken.current = refreshToken; + void refreshAssetUrl().catch(() => undefined); + }, [ + assetLoading, + connectionReady, + props.kind, + props.workspaceMutationId, + refreshAssetUrl, + refreshToken, + resource._tag, + ]); + const path = + resource._tag === "media-file" + ? resource.path + : resource._tag === "workspace-file" && props.workspaceRoot + ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` + : undefined; + const reference = path + ? mediaFileReference(path, props.workspaceRoot) + : props.originalUrl + ? mediaUrlReference(props.originalUrl) + : undefined; + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const src = + assetUrl._tag === "Success" + ? assetUrl.url + (props.srcFragment ?? "") + : fallbackSrc === undefined + ? null + : fallbackSrc + (props.srcFragment ?? ""); + const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; + const maxHeightRem = props.maxHeightRem ?? 30; + const style = + props.style ?? + (knownSize + ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) + : maxHeightRem !== 30 + ? { maxHeight: `${maxHeightRem}rem` } + : undefined); + const actionsSource: MediaActionSource = { + kind: props.kind ?? "image", + name: props.alt || (props.kind ?? "image"), + src, + ...(fallbackSrc === undefined + ? { asset: { environmentId: props.environmentId, resource } } + : {}), + ...(reference ? { reference } : {}), + ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") + ? { + onOpenFile: () => + useRightPanelStore + .getState() + .openFile( + { environmentId: props.environmentId, threadId: resource.threadId }, + relativePath, + ), + } + : {}), + }; + + if (props.kind === "video") { + return ( + + ); + } + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index cf72597f43ec..457e960e9fb6 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -28,7 +28,8 @@ import { workEntrySignalsSevereFailure, workLogEntryIsToolLike, } from "../../session-logic"; -import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; +import ChatMarkdown from "../ChatMarkdown"; +import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia"; import { ChevronRightIcon, CircleAlertIcon, XIcon, RotateCcwIcon } from "lucide-react"; import { Button, InlineButton } from "../ui/button"; import { useAssetUrls } from "../../assets/assetUrls"; diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 991ce50aa2e9..a93e8a5b611b 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -24,7 +24,8 @@ import { useTurnItemDetail } from "../../state/queries"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { Button } from "../ui/button"; -import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; +import ChatMarkdown from "../ChatMarkdown"; +import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPanel"; import { resolveExternalWebLinkHref } from "./externalLinkContextMenu";