fix: address PR #6150 review feedback

- Consolidate duplicate getMimeType functions into shared utilities
- Remove duplicate MediaThumbnails component, enhance Thumbnails to support video
- Add JSDoc comments to VideoContentBlock interface
- Convert inline styles to Tailwind classes in ChatRow
- Add robust error handling for video processing
- Create centralized media configuration for accepted file types
- Ensure consistent test naming conventions
- Fix ESLint warnings
This commit is contained in:
hannesrudolph 2025-07-24 00:06:41 -06:00
parent ec674d2673
commit 2ac9984e28
12 changed files with 378 additions and 276 deletions

View file

@ -1,7 +1,17 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, Part } from "@google/genai"
// Extended type to support video content blocks that aren't in the standard Anthropic SDK
/**
* Extended content block type to support video content that isn't in the standard Anthropic SDK.
* This interface extends the standard Anthropic content blocks to include video support for Gemini models.
*
* @interface VideoContentBlock
* @property {string} type - Must be "video" to identify this as a video content block
* @property {Object} source - The video source information
* @property {string} source.type - Must be "base64" for base64-encoded video data
* @property {string} source.data - The base64-encoded video data
* @property {string} source.media_type - The MIME type of the video (e.g., "video/mp4", "video/webm")
*/
interface VideoContentBlock {
type: "video"
source: {
@ -11,6 +21,10 @@ interface VideoContentBlock {
}
}
/**
* Extended content block parameter type that includes both standard Anthropic content blocks
* and our custom video content block for Gemini model support.
*/
type ExtendedContentBlockParam = Anthropic.ContentBlockParam | VideoContentBlock
export function convertAnthropicContentToGemini(content: string | ExtendedContentBlockParam[]): Part[] {
@ -28,11 +42,39 @@ export function convertAnthropicContentToGemini(content: string | ExtendedConten
}
return { inlineData: { data: block.source.data, mimeType: block.source.media_type } }
case "video":
case "video": {
if (block.source.type !== "base64") {
throw new Error("Unsupported video source type")
throw new Error("Unsupported video source type. Only base64 encoded videos are supported.")
}
// Validate video MIME type
const supportedVideoTypes = ["video/mp4", "video/webm", "video/ogg", "video/quicktime"]
if (!supportedVideoTypes.includes(block.source.media_type)) {
throw new Error(
`Unsupported video format: ${block.source.media_type}. Supported formats: ${supportedVideoTypes.join(", ")}`,
)
}
// Check if video data exists
if (!block.source.data || block.source.data.trim() === "") {
throw new Error("Video data is empty or missing")
}
// Validate base64 format
try {
// Basic validation - check if it's valid base64
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
if (!base64Regex.test(block.source.data.replace(/\s/g, ""))) {
throw new Error("Invalid base64 format for video data")
}
} catch (e) {
throw new Error(
`Failed to validate video data: ${e instanceof Error ? e.message : "Unknown error"}`,
)
}
return { inlineData: { data: block.source.data, mimeType: block.source.media_type } }
}
case "tool_use":
return {
functionCall: {

View file

@ -1,6 +1,6 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import { getMimeType } from "../../shared/utils/media"
export async function selectImages(): Promise<string[]> {
const options: vscode.OpenDialogOptions = {
@ -23,23 +23,11 @@ export async function selectImages(): Promise<string[]> {
const buffer = await fs.readFile(imagePath)
const base64 = buffer.toString("base64")
const mimeType = getMimeType(imagePath)
if (!mimeType) {
throw new Error(`Unsupported file type: ${imagePath}`)
}
const dataUrl = `data:${mimeType};base64,${base64}`
return dataUrl
}),
)
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".png":
return "image/png"
case ".jpeg":
case ".jpg":
return "image/jpeg"
case ".webp":
return "image/webp"
default:
throw new Error(`Unsupported file type: ${ext}`)
}
}

54
src/shared/utils/media.ts Normal file
View file

@ -0,0 +1,54 @@
import * as path from "path"
/**
* Get MIME type from either a file path or a data URI
* @param input - Either a file path or a data URI
* @returns The MIME type or null if not found
*/
export function getMimeType(input: string): string | null {
// Check if it's a data URI
if (input.startsWith("data:")) {
const match = input.match(/^data:(.*?);/)
return match ? match[1] : null
}
// Otherwise, treat it as a file path
const ext = path.extname(input).toLowerCase()
switch (ext) {
case ".png":
return "image/png"
case ".jpeg":
case ".jpg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".ogg":
return "video/ogg"
case ".mov":
return "video/quicktime"
default:
return null
}
}
/**
* Check if a MIME type represents a video
* @param mimeType - The MIME type to check
* @returns True if it's a video MIME type
*/
export function isVideoMimeType(mimeType: string | null): boolean {
return mimeType?.startsWith("video/") ?? false
}
/**
* Check if a MIME type represents an image
* @param mimeType - The MIME type to check
* @returns True if it's an image MIME type
*/
export function isImageMimeType(mimeType: string | null): boolean {
return mimeType?.startsWith("image/") ?? false
}

View file

@ -201,37 +201,26 @@ export const ChatRowContent = ({
const type = message.type === "ask" ? message.ask : message.say
const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)"
const successColor = "var(--vscode-charts-green)"
const cancelledColor = "var(--vscode-descriptionForeground)"
const [icon, title] = useMemo(() => {
switch (type) {
case "error":
return [
<span
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:error")}</span>,
<span className="codicon codicon-error text-vscode-errorForeground -mb-[1.5px]"></span>,
<span className="text-vscode-errorForeground font-bold">{t("chat:error")}</span>,
]
case "mistake_limit_reached":
return [
<span
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:troubleMessage")}</span>,
<span className="codicon codicon-error text-vscode-errorForeground -mb-[1.5px]"></span>,
<span className="text-vscode-errorForeground font-bold">{t("chat:troubleMessage")}</span>,
]
case "command":
return [
isCommandExecuting ? (
<ProgressIndicator />
) : (
<span
className="codicon codicon-terminal"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
<span className="codicon codicon-terminal text-vscode-foreground -mb-[1.5px]"></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:runCommand.title")}:</span>,
<span className="text-vscode-foreground font-bold">{t("chat:runCommand.title")}:</span>,
]
case "use_mcp_server":
const mcpServerUse = safeJsonParse<ClineAskUseMcpServer>(message.text)
@ -242,11 +231,9 @@ export const ChatRowContent = ({
isMcpServerResponding ? (
<ProgressIndicator />
) : (
<span
className="codicon codicon-server"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
<span className="codicon codicon-server text-vscode-foreground -mb-[1.5px]"></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>
<span className="text-vscode-foreground font-bold">
{mcpServerUse.type === "use_mcp_tool"
? t("chat:mcp.wantsToUseTool", { serverName: mcpServerUse.serverName })
: t("chat:mcp.wantsToAccessResource", { serverName: mcpServerUse.serverName })}
@ -254,88 +241,60 @@ export const ChatRowContent = ({
]
case "completion_result":
return [
<span
className="codicon codicon-check"
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>{t("chat:taskCompleted")}</span>,
<span className="codicon codicon-check text-vscode-charts-green -mb-[1.5px]"></span>,
<span className="text-vscode-charts-green font-bold">{t("chat:taskCompleted")}</span>,
]
case "api_req_retry_delayed":
return []
case "api_req_started":
const getIconSpan = (iconName: string, color: string) => (
<div
style={{
width: 16,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<span
className={`codicon codicon-${iconName}`}
style={{ color, fontSize: 16, marginBottom: "-1.5px" }}
/>
const getIconSpan = (iconName: string, colorClass: string) => (
<div className="w-4 h-4 flex items-center justify-center">
<span className={`codicon codicon-${iconName} ${colorClass} text-base -mb-[1.5px]`} />
</div>
)
return [
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
apiReqCancelReason === "user_cancelled" ? (
getIconSpan("error", cancelledColor)
getIconSpan("error", "text-vscode-descriptionForeground")
) : (
getIconSpan("error", errorColor)
getIconSpan("error", "text-vscode-errorForeground")
)
) : cost !== null && cost !== undefined ? (
getIconSpan("check", successColor)
getIconSpan("check", "text-vscode-charts-green")
) : apiRequestFailedMessage ? (
getIconSpan("error", errorColor)
getIconSpan("error", "text-vscode-errorForeground")
) : (
<ProgressIndicator />
),
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
apiReqCancelReason === "user_cancelled" ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>
{t("chat:apiRequest.cancelled")}
</span>
<span className="text-vscode-foreground font-bold">{t("chat:apiRequest.cancelled")}</span>
) : (
<span style={{ color: errorColor, fontWeight: "bold" }}>
<span className="text-vscode-errorForeground font-bold">
{t("chat:apiRequest.streamingFailed")}
</span>
)
) : cost !== null && cost !== undefined ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.title")}</span>
<span className="text-vscode-foreground font-bold">{t("chat:apiRequest.title")}</span>
) : apiRequestFailedMessage ? (
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:apiRequest.failed")}</span>
<span className="text-vscode-errorForeground font-bold">{t("chat:apiRequest.failed")}</span>
) : (
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.streaming")}</span>
<span className="text-vscode-foreground font-bold">{t("chat:apiRequest.streaming")}</span>
),
]
case "followup":
return [
<span
className="codicon codicon-question"
style={{ color: normalColor, marginBottom: "-1.5px" }}
/>,
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:questions.hasQuestion")}</span>,
<span className="codicon codicon-question text-vscode-foreground -mb-[1.5px]" />,
<span className="text-vscode-foreground font-bold">{t("chat:questions.hasQuestion")}</span>,
]
default:
return [null, null]
}
}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t])
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
wordBreak: "break-word",
}
const headerClassName = "flex items-center gap-2.5 mb-2.5 break-words"
const pStyle: React.CSSProperties = {
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}
const pClassName = "m-0 whitespace-pre-wrap break-words overflow-wrap-anywhere"
const tool = useMemo(
() => (message.ask === "tool" ? safeJsonParse<ClineSayTool>(message.text) : null),
@ -351,9 +310,7 @@ export const ChatRowContent = ({
if (tool) {
const toolIcon = (name: string) => (
<span
className={`codicon codicon-${name}`}
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
<span className={`codicon codicon-${name} text-vscode-foreground -mb-[1.5px]`}></span>
)
switch (tool.tool) {
@ -363,11 +320,9 @@ export const ChatRowContent = ({
if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("diff")}
<span style={{ fontWeight: "bold" }}>
{t("chat:fileOperations.wantsToApplyBatchChanges")}
</span>
<span className="font-bold">{t("chat:fileOperations.wantsToApplyBatchChanges")}</span>
</div>
<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
</>
@ -377,16 +332,13 @@ export const ChatRowContent = ({
// Regular single file diff
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
) : (
toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit")
)}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{tool.isProtected
? t("chat:fileOperations.wantsToEditProtected")
: tool.isOutsideWorkspace
@ -408,16 +360,13 @@ export const ChatRowContent = ({
case "insertContent":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
) : (
toolIcon("insert")
)}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{tool.isProtected
? t("chat:fileOperations.wantsToEditProtected")
: tool.isOutsideWorkspace
@ -443,16 +392,13 @@ export const ChatRowContent = ({
case "searchAndReplace":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
) : (
toolIcon("replace")
)}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{tool.isProtected && message.type === "ask"
? t("chat:fileOperations.wantsToEditProtected")
: message.type === "ask"
@ -473,9 +419,9 @@ export const ChatRowContent = ({
)
case "codebaseSearch": {
return (
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("search")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{tool.path ? (
<Trans
i18nKey="chat:codebaseSearch.wantsToSearchWithPath"
@ -511,16 +457,13 @@ export const ChatRowContent = ({
case "newFileCreated":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
) : (
toolIcon("new-file")
)}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{tool.isProtected
? t("chat:fileOperations.wantsToEditProtected")
: t("chat:fileOperations.wantsToCreate")}
@ -544,11 +487,9 @@ export const ChatRowContent = ({
if (isBatchRequest) {
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("files")}
<span style={{ fontWeight: "bold" }}>
{t("chat:fileOperations.wantsToReadMultiple")}
</span>
<span className="font-bold">{t("chat:fileOperations.wantsToReadMultiple")}</span>
</div>
<BatchFilePermission
files={tool.batchFiles || []}
@ -564,9 +505,9 @@ export const ChatRowContent = ({
// Regular single file read request
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:fileOperations.wantsToReadOutsideWorkspace")
@ -598,9 +539,9 @@ export const ChatRowContent = ({
case "fetchInstructions":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>{t("chat:instructions.wantsToFetch")}</span>
<span className="font-bold">{t("chat:instructions.wantsToFetch")}</span>
</div>
<CodeAccordian
code={tool.content}
@ -614,9 +555,9 @@ export const ChatRowContent = ({
case "listFilesTopLevel":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("folder-opened")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace")
@ -638,9 +579,9 @@ export const ChatRowContent = ({
case "listFilesRecursive":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("folder-opened")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace")
@ -662,9 +603,9 @@ export const ChatRowContent = ({
case "listCodeDefinitionNames":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:directoryOperations.wantsToViewDefinitionsOutsideWorkspace")
@ -686,9 +627,9 @@ export const ChatRowContent = ({
case "searchFiles":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("search")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{message.type === "ask" ? (
<Trans
i18nKey={
@ -724,9 +665,9 @@ export const ChatRowContent = ({
case "switchMode":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("symbol-enum")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
{message.type === "ask" ? (
<>
{tool.reason ? (
@ -767,9 +708,9 @@ export const ChatRowContent = ({
case "newTask":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("tasklist")}
<span style={{ fontWeight: "bold" }}>
<span className="font-bold">
<Trans
i18nKey="chat:subtasks.wantsToCreate"
components={{ code: <code>{tool.mode}</code> }}
@ -810,9 +751,9 @@ export const ChatRowContent = ({
case "finishTask":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{toolIcon("check-all")}
<span style={{ fontWeight: "bold" }}>{t("chat:subtasks.wantsToFinish")}</span>
<span className="font-bold">{t("chat:subtasks.wantsToFinish")}</span>
</div>
<div
style={{
@ -890,7 +831,7 @@ export const ChatRowContent = ({
fontSize: 16,
marginBottom: "-1.5px",
}}></span>
<span style={{ fontWeight: "bold" }}>{t("chat:diffError.title")}</span>
<span className="font-bold">{t("chat:diffError.title")}</span>
</div>
<div style={{ display: "flex", alignItems: "center" }}>
<VSCodeButton
@ -991,19 +932,13 @@ export const ChatRowContent = ({
return (
<>
<div
className={`${headerClassName} justify-between cursor-pointer select-none`}
style={{
...headerStyle,
marginBottom:
((cost === null || cost === undefined) && apiRequestFailedMessage) ||
apiReqStreamingFailedMessage
? 10
: 0,
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
onClick={handleToggleExpand}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
@ -1019,7 +954,7 @@ export const ChatRowContent = ({
{(((cost === null || cost === undefined) && apiRequestFailedMessage) ||
apiReqStreamingFailedMessage) && (
<>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
<p className={`${pClassName} text-vscode-errorForeground`}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
@ -1135,18 +1070,18 @@ export const ChatRowContent = ({
return (
<>
{title && (
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>
)}
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
<p className={`${pClassName} text-vscode-errorForeground`}>{message.text}</p>
</>
)
case "completion_result":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>
@ -1209,7 +1144,7 @@ export const ChatRowContent = ({
return (
<>
{title && (
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>
@ -1225,11 +1160,11 @@ export const ChatRowContent = ({
case "mistake_limit_reached":
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
<p className={`${pClassName} text-vscode-errorForeground`}>{message.text}</p>
</>
)
case "command":
@ -1262,7 +1197,7 @@ export const ChatRowContent = ({
return (
<>
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>
@ -1304,7 +1239,7 @@ export const ChatRowContent = ({
if (message.text) {
return (
<div>
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>
@ -1320,7 +1255,7 @@ export const ChatRowContent = ({
return (
<>
{title && (
<div style={headerStyle}>
<div className={headerClassName}>
{icon}
{title}
</div>

View file

@ -23,7 +23,7 @@ import { SelectDropdown, DropdownOptionType, Button, StandardTooltip } from "@/c
import ModeSelector from "./ModeSelector"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import MediaThumbnails from "../common/MediaThumbnails"
import Thumbnails from "../common/Thumbnails"
import ContextMenu from "./ContextMenu"
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide-react"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
@ -1268,9 +1268,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
{selectedMedia.length > 0 && (
<MediaThumbnails
mediaItems={selectedMedia}
setMediaItems={setSelectedMedia}
<Thumbnails
images={selectedMedia}
setImages={setSelectedMedia}
style={{
left: "16px",
zIndex: 2,

View file

@ -40,6 +40,7 @@ import RooTips from "@src/components/welcome/RooTips"
import { StandardTooltip } from "@src/components/ui"
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
import { getAcceptedFileTypes } from "@src/utils/media-config"
import TelemetryBanner from "../common/TelemetryBanner"
import VersionIndicator from "../common/VersionIndicator"
@ -702,27 +703,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), [])
const acceptedFileTypes = useMemo(() => {
const modelId = apiConfiguration?.apiModelId
const isGeminiPro = modelId?.includes("gemini-2.5-pro")
const isGeminiFlash =
modelId?.includes("gemini-1.5-flash") ||
modelId?.includes("gemini-2.0-flash-001") ||
modelId?.includes("gemini-2.5-flash-preview-05-20") ||
modelId?.includes("gemini-2.5-flash") ||
modelId?.includes("gemini-2.0-flash-lite-preview-02-05") ||
modelId?.includes("gemini-2.0-flash-thinking-exp-01-21") ||
modelId?.includes("gemini-2.0-flash-thinking-exp-1219") ||
modelId?.includes("gemini-2.0-flash-exp") ||
modelId?.includes("gemini-2.5-flash-lite-preview-06-17")
if ((isGeminiPro || isGeminiFlash) && model?.supportsImages) {
return ["png", "jpeg", "webp", "heic", "heif", "mp4", "mov", "avi", "wmv", "flv", "webm"]
}
if (model?.supportsImages) {
return ["png", "jpeg", "webp", "heic", "heif"]
}
return []
}, [apiConfiguration, model])
return getAcceptedFileTypes(apiConfiguration?.apiModelId, model?.supportsImages)
}, [apiConfiguration?.apiModelId, model?.supportsImages])
const shouldDisableImages =
!model?.supportsImages || sendingDisabled || selectedMedia.length >= MAX_IMAGES_PER_MESSAGE

View file

@ -5,6 +5,7 @@ import { defaultModeSlug } from "@roo/modes"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import * as pathMentions from "@src/utils/path-mentions"
import { BASE_IMAGE_FORMATS, VIDEO_FORMATS } from "@src/utils/media-config"
import ChatTextArea from "../ChatTextArea"
@ -60,7 +61,7 @@ describe("ChatTextArea", () => {
mode: defaultModeSlug,
setMode: vi.fn(),
modeShortcutText: "(⌘. for next mode)",
acceptedFileTypes: ["png", "jpeg", "gif", "mp4"],
acceptedFileTypes: [...BASE_IMAGE_FORMATS, ...VIDEO_FORMATS],
}
beforeEach(() => {

View file

@ -1,43 +0,0 @@
import React from "react"
import { FileVideo, X } from "lucide-react"
import { getMimeType } from "../../utils/getMimeType"
interface MediaThumbnailsProps {
mediaItems: string[]
setMediaItems: React.Dispatch<React.SetStateAction<string[]>>
style?: React.CSSProperties
}
const MediaThumbnails: React.FC<MediaThumbnailsProps> = ({ mediaItems, setMediaItems, style }) => {
const handleRemoveImage = (index: number) => {
setMediaItems((prevImages) => prevImages.filter((_, i) => i !== index))
}
return (
<div className="flex flex-wrap gap-2 p-2 bg-vscode-input-background" style={style}>
{mediaItems.map((item, index) => {
const mimeType = getMimeType(item)
const isVideo = mimeType?.startsWith("video/")
return (
<div key={index} className="relative w-16 h-16">
{isVideo ? (
<div className="w-full h-full flex items-center justify-center bg-vscode-input-background rounded">
<FileVideo className="w-8 h-8 text-vscode-descriptionForeground" />
</div>
) : (
<img src={item} alt={`thumbnail ${index}`} className="w-full h-full object-cover rounded" />
)}
<button
onClick={() => handleRemoveImage(index)}
className="absolute top-0 right-0 bg-red-500 text-white rounded-full p-1">
<X size={12} />
</button>
</div>
)
})}
</div>
)
}
export default MediaThumbnails

View file

@ -1,6 +1,8 @@
import React, { useState, useRef, useLayoutEffect, memo } from "react"
import { useWindowSize } from "react-use"
import { vscode } from "@src/utils/vscode"
import { getMimeType, isVideoMimeType } from "../../utils/media"
import { FileVideo } from "lucide-react"
interface ThumbnailsProps {
images: string[]
@ -46,51 +48,85 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
rowGap: 3,
...style,
}}>
{images.map((image, index) => (
<div
key={index}
style={{ position: "relative" }}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}>
<img
src={image}
alt={`Thumbnail ${index + 1}`}
style={{
width: 34,
height: 34,
objectFit: "cover",
borderRadius: 4,
cursor: "pointer",
}}
onClick={() => handleImageClick(image)}
/>
{isDeletable && hoveredIndex === index && (
<div
onClick={() => handleDelete(index)}
style={{
position: "absolute",
top: -4,
right: -4,
width: 13,
height: 13,
borderRadius: "50%",
backgroundColor: "var(--vscode-badge-background)",
display: "flex",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}>
<span
className="codicon codicon-close"
{images.map((image, index) => {
const mimeType = getMimeType(image)
const isVideo = isVideoMimeType(mimeType)
return (
<div
key={index}
style={{ position: "relative" }}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}>
{isVideo ? (
<div
style={{
color: "var(--vscode-foreground)",
fontSize: 10,
fontWeight: "bold",
}}></span>
</div>
)}
</div>
))}
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "var(--vscode-input-background)",
borderRadius: 4,
cursor: "pointer",
}}
onClick={() => handleImageClick(image)}
title={`Video: ${mimeType || "Unknown format"}`}>
<FileVideo size={20} style={{ color: "var(--vscode-descriptionForeground)" }} />
</div>
) : (
<img
src={image}
alt={`Thumbnail ${index + 1}`}
style={{
width: 34,
height: 34,
objectFit: "cover",
borderRadius: 4,
cursor: "pointer",
}}
onClick={() => handleImageClick(image)}
onError={(e) => {
// Handle image load errors
const target = e.target as HTMLImageElement
target.style.display = "none"
const errorDiv = document.createElement("div")
errorDiv.style.cssText =
"width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; background-color: var(--vscode-input-background); border-radius: 4px; font-size: 10px; color: var(--vscode-errorForeground);"
errorDiv.textContent = "!"
errorDiv.title = "Failed to load image"
target.parentNode?.appendChild(errorDiv)
}}
/>
)}
{isDeletable && hoveredIndex === index && (
<div
onClick={() => handleDelete(index)}
style={{
position: "absolute",
top: -4,
right: -4,
width: 13,
height: 13,
borderRadius: "50%",
backgroundColor: "var(--vscode-badge-background)",
display: "flex",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}>
<span
className="codicon codicon-close"
style={{
color: "var(--vscode-foreground)",
fontSize: 10,
fontWeight: "bold",
}}></span>
</div>
)}
</div>
)
})}
</div>
)
}

View file

@ -1,4 +0,0 @@
export function getMimeType(dataUri: string): string | null {
const match = dataUri.match(/^data:(.*?);/)
return match ? match[1] : null
}

View file

@ -0,0 +1,84 @@
/**
* Configuration for accepted media file types by model
*/
export interface MediaConfig {
images: string[]
videos: string[]
}
// Base image formats supported by most models
export const BASE_IMAGE_FORMATS = ["png", "jpeg", "webp", "heic", "heif"]
// Video formats supported by Gemini models
export const VIDEO_FORMATS = ["mp4", "mov", "avi", "wmv", "flv", "webm"]
// Configuration for different model types
export const MEDIA_CONFIG: Record<string, MediaConfig> = {
// Gemini Pro and Flash models support both images and videos
gemini_full: {
images: BASE_IMAGE_FORMATS,
videos: VIDEO_FORMATS,
},
// Default configuration for models that only support images
default: {
images: BASE_IMAGE_FORMATS,
videos: [],
},
// Configuration for models that don't support any media
none: {
images: [],
videos: [],
},
}
/**
* Get accepted file types for a given model
* @param modelId - The model ID
* @param supportsImages - Whether the model supports images
* @returns Array of accepted file extensions
*/
export function getAcceptedFileTypes(modelId: string | undefined, supportsImages: boolean | undefined): string[] {
if (!supportsImages) {
return []
}
// Check if it's a Gemini model that supports video
const isGeminiWithVideo =
modelId?.includes("gemini-2.5-pro") ||
modelId?.includes("gemini-1.5-flash") ||
modelId?.includes("gemini-2.0-flash-001") ||
modelId?.includes("gemini-2.5-flash-preview-05-20") ||
modelId?.includes("gemini-2.5-flash") ||
modelId?.includes("gemini-2.0-flash-lite-preview-02-05") ||
modelId?.includes("gemini-2.0-flash-thinking-exp-01-21") ||
modelId?.includes("gemini-2.0-flash-thinking-exp-1219") ||
modelId?.includes("gemini-2.0-flash-exp") ||
modelId?.includes("gemini-2.5-flash-lite-preview-06-17")
if (isGeminiWithVideo) {
const config = MEDIA_CONFIG.gemini_full
return [...config.images, ...config.videos]
}
// Default to image-only support
return MEDIA_CONFIG.default.images
}
/**
* Check if a file type is an image
* @param fileType - The file extension (without dot)
* @returns true if the file type is an image
*/
export function isImageFileType(fileType: string): boolean {
return BASE_IMAGE_FORMATS.includes(fileType.toLowerCase())
}
/**
* Check if a file type is a video
* @param fileType - The file extension (without dot)
* @returns true if the file type is a video
*/
export function isVideoFileType(fileType: string): boolean {
return VIDEO_FORMATS.includes(fileType.toLowerCase())
}

View file

@ -0,0 +1,27 @@
/**
* Get MIME type from a data URI
* @param dataUri - A data URI string
* @returns The MIME type or null if not found
*/
export function getMimeType(dataUri: string): string | null {
const match = dataUri.match(/^data:(.*?);/)
return match ? match[1] : null
}
/**
* Check if a MIME type represents a video
* @param mimeType - The MIME type to check
* @returns True if it's a video MIME type
*/
export function isVideoMimeType(mimeType: string | null): boolean {
return mimeType?.startsWith("video/") ?? false
}
/**
* Check if a MIME type represents an image
* @param mimeType - The MIME type to check
* @returns True if it's an image MIME type
*/
export function isImageMimeType(mimeType: string | null): boolean {
return mimeType?.startsWith("image/") ?? false
}