From 202e3a6d057474a256c0e021439e52e4215f4716 Mon Sep 17 00:00:00 2001
From: Andrei Edell
Date: Tue, 25 Feb 2025 10:39:14 -1000
Subject: [PATCH 01/25] Hugelung/rich mcp response (#1941)
* showing images after mcp responses
* images now open in a webview tab
* Open Graph link metadata display for MCP responses
* almost totally working rich mcp response display with images and embeds
* closer
* header for response display
* updated styling of mcp responses
* default to plain text if rich response is loading
* formatting fix
* added changeset output
* remove some old code
* add the dashed border back
* avoid XSS attacks by sanitizing the preview image urls and embeds
* remove incorrect vendor prefix css
* delete old version of open image implementation
* undo some comment removals and cleanups to make PR easier to read
---------
Co-authored-by: Andrei Edell
---
.changeset/old-dancers-smell.md | 5 +
package-lock.json | 22 +
package.json | 1 +
src/core/webview/ClineProvider.ts | 59 +++
src/integrations/misc/link-preview.ts | 107 ++++
src/shared/ExtensionMessage.ts | 12 +
src/shared/WebviewMessage.ts | 7 +
webview-ui/package-lock.json | 12 +
webview-ui/package.json | 2 +
webview-ui/src/components/chat/ChatRow.tsx | 290 +++++------
webview-ui/src/components/mcp/LinkPreview.tsx | 188 +++++++
.../src/components/mcp/McpResponseDisplay.tsx | 460 ++++++++++++++++++
12 files changed, 990 insertions(+), 175 deletions(-)
create mode 100644 .changeset/old-dancers-smell.md
create mode 100644 src/integrations/misc/link-preview.ts
create mode 100644 webview-ui/src/components/mcp/LinkPreview.tsx
create mode 100644 webview-ui/src/components/mcp/McpResponseDisplay.tsx
diff --git a/.changeset/old-dancers-smell.md b/.changeset/old-dancers-smell.md
new file mode 100644
index 0000000000..45c0af9d1c
--- /dev/null
+++ b/.changeset/old-dancers-smell.md
@@ -0,0 +1,5 @@
+---
+"claude-dev": minor
+---
+
+Add rich MCP responses with images and link previews
diff --git a/package-lock.json b/package-lock.json
index 2bafe4a886..0dae4ee453 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,6 +36,7 @@
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
+ "open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
@@ -10665,6 +10666,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/open-graph-scraper": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.9.0.tgz",
+ "integrity": "sha512-1KoV5v6GT0/MqlryrVGQROhEAD4u8wC3VjYOxsnhj3mWeGJ6N6nF/rbrcZREFr+kiYm9I5LMrzdK9t9hBMbL2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "chardet": "^2.0.0",
+ "cheerio": "^1.0.0-rc.12",
+ "iconv-lite": "^0.6.3",
+ "undici": "^6.21.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/open-graph-scraper/node_modules/chardet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.0.0.tgz",
+ "integrity": "sha512-xVgPpulCooDjY6zH4m9YW3jbkaBe3FKIAvF5sj5t7aBNsVl2ljIE+xwJ4iNgiDZHFQvNIpjdKdVOQvvk5ZfxbQ==",
+ "license": "MIT"
+ },
"node_modules/openai": {
"version": "4.83.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.83.0.tgz",
diff --git a/package.json b/package.json
index 74d72afe49..9fa64f199a 100644
--- a/package.json
+++ b/package.json
@@ -264,6 +264,7 @@
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
+ "open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index a042e92363..5c486c1521 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -10,6 +10,7 @@ import * as vscode from "vscode"
import { buildApiHandler } from "../../api"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { openFile, openImage } from "../../integrations/misc/open-file"
+import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
@@ -663,6 +664,17 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "openImage":
openImage(message.text!)
break
+ case "openInBrowser":
+ if (message.url) {
+ vscode.env.openExternal(vscode.Uri.parse(message.url))
+ }
+ break
+ case "fetchOpenGraphData":
+ this.fetchOpenGraphData(message.text!)
+ break
+ case "checkIsImageUrl":
+ this.checkIsImageUrl(message.text!)
+ break
case "openFile":
openFile(message.text!)
break
@@ -1955,6 +1967,53 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
return await this.context.secrets.get(key)
}
+ // Open Graph Data
+
+ async fetchOpenGraphData(url: string) {
+ try {
+ // Use the fetchOpenGraphData function from link-preview.ts
+ const ogData = await fetchOpenGraphData(url)
+
+ // Send the data back to the webview
+ await this.postMessageToWebview({
+ type: "openGraphData",
+ openGraphData: ogData,
+ url: url,
+ })
+ } catch (error) {
+ console.error(`Error fetching Open Graph data for ${url}:`, error)
+ // Send an error response
+ await this.postMessageToWebview({
+ type: "openGraphData",
+ error: `Failed to fetch Open Graph data: ${error}`,
+ url: url,
+ })
+ }
+ }
+
+ // Check if a URL is an image
+ async checkIsImageUrl(url: string) {
+ try {
+ // Check if the URL is an image
+ const isImage = await isImageUrl(url)
+
+ // Send the result back to the webview
+ await this.postMessageToWebview({
+ type: "isImageUrlResult",
+ isImage,
+ url,
+ })
+ } catch (error) {
+ console.error(`Error checking if URL is an image: ${url}`, error)
+ // Send an error response
+ await this.postMessageToWebview({
+ type: "isImageUrlResult",
+ isImage: false,
+ url,
+ })
+ }
+ }
+
// dev
async resetState() {
diff --git a/src/integrations/misc/link-preview.ts b/src/integrations/misc/link-preview.ts
new file mode 100644
index 0000000000..ad2bdea194
--- /dev/null
+++ b/src/integrations/misc/link-preview.ts
@@ -0,0 +1,107 @@
+import axios from "axios"
+import ogs from "open-graph-scraper"
+
+export interface OpenGraphData {
+ title?: string
+ description?: string
+ image?: string
+ url?: string
+ siteName?: string
+ type?: string
+}
+
+/**
+ * Fetches Open Graph metadata from a URL
+ * @param url The URL to fetch metadata from
+ * @returns Promise resolving to OpenGraphData
+ */
+export async function fetchOpenGraphData(url: string): Promise {
+ try {
+ const options = {
+ url: url,
+ timeout: 5000,
+ headers: {
+ "user-agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
+ },
+ onlyGetOpenGraphInfo: false, // Get all metadata, not just Open Graph
+ fetchOptions: {
+ redirect: "follow", // Follow redirects
+ } as any,
+ }
+
+ const { result } = await ogs(options)
+
+ // Use type assertion to avoid TypeScript errors
+ const data = result as any
+
+ // Handle image URLs
+ let imageUrl = data.ogImage?.[0]?.url || data.twitterImage?.[0]?.url
+
+ // If the image URL is relative, make it absolute
+ if (imageUrl && (imageUrl.startsWith("/") || imageUrl.startsWith("./"))) {
+ try {
+ // Extract the base URL and make the relative URL absolute
+ const urlObj = new URL(url)
+ const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`
+ imageUrl = new URL(imageUrl, baseUrl).href
+ } catch (error) {
+ console.error(`Error converting relative URL to absolute: ${imageUrl}`, error)
+ }
+ }
+
+ return {
+ title: data.ogTitle || data.twitterTitle || data.dcTitle || data.title || new URL(url).hostname,
+ description:
+ data.ogDescription ||
+ data.twitterDescription ||
+ data.dcDescription ||
+ data.description ||
+ "No description available",
+ image: imageUrl,
+ url: data.ogUrl || url,
+ siteName: data.ogSiteName || new URL(url).hostname,
+ type: data.ogType,
+ }
+ } catch (error) {
+ console.error(`Error fetching Open Graph data for ${url}:`, error)
+ // Return basic information based on the URL
+ try {
+ const urlObj = new URL(url)
+ return {
+ title: urlObj.hostname,
+ description: url,
+ url: url,
+ siteName: urlObj.hostname,
+ }
+ } catch {
+ return {
+ title: url,
+ description: url,
+ url: url,
+ }
+ }
+ }
+}
+
+/**
+ * Checks if a URL is an image by making a HEAD request and checking the content type
+ * @param url The URL to check
+ * @returns Promise resolving to boolean indicating if the URL is an image
+ */
+export async function isImageUrl(url: string): Promise {
+ try {
+ const response = await axios.head(url, {
+ headers: {
+ "User-Agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
+ },
+ timeout: 3000,
+ })
+
+ const contentType = response.headers["content-type"]
+ return contentType && contentType.startsWith("image/")
+ } catch (error) {
+ console.error(`Error checking if URL is an image: ${url}`, error)
+ // If we can't determine, fall back to checking the file extension
+ return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
+ }
+}
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 7ee199fffb..bcad63b788 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -31,6 +31,8 @@ export interface ExtensionMessage {
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "commitSearchResults"
+ | "openGraphData"
+ | "isImageUrlResult"
text?: string
action?:
| "chatButtonClicked"
@@ -55,6 +57,16 @@ export interface ExtensionMessage {
error?: string
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
+ openGraphData?: {
+ title?: string
+ description?: string
+ image?: string
+ url?: string
+ siteName?: string
+ type?: string
+ }
+ url?: string
+ isImage?: boolean
}
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index cd4e877604..873a7390f9 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -22,6 +22,7 @@ export interface WebviewMessage {
| "requestOllamaModels"
| "requestLmStudioModels"
| "openImage"
+ | "openInBrowser"
| "openFile"
| "openMention"
| "cancelTask"
@@ -52,6 +53,9 @@ export interface WebviewMessage {
| "fetchLatestMcpServersFromHub"
| "telemetrySetting"
| "openSettings"
+ | "updateMcpTimeout"
+ | "fetchOpenGraphData"
+ | "checkIsImageUrl"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@@ -70,6 +74,9 @@ export interface WebviewMessage {
serverName?: string
toolName?: string
autoApprove?: boolean
+
+ // For openInBrowser
+ url?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json
index 1fa57354ba..81b6f05e6f 100644
--- a/webview-ui/package-lock.json
+++ b/webview-ui/package-lock.json
@@ -9,8 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@floating-ui/react": "^0.27.4",
+ "@types/dompurify": "^3.0.5",
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
+ "dompurify": "^3.2.4",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
@@ -4979,6 +4981,15 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@@ -8981,6 +8992,7 @@
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
diff --git a/webview-ui/package.json b/webview-ui/package.json
index fd3743ad01..97df450bbe 100644
--- a/webview-ui/package.json
+++ b/webview-ui/package.json
@@ -4,8 +4,10 @@
"private": true,
"dependencies": {
"@floating-ui/react": "^0.27.4",
+ "@types/dompurify": "^3.0.5",
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
+ "dompurify": "^3.2.4",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index f77019fc3f..88f82c1366 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -25,6 +25,7 @@ import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import { highlightMentions } from "./TaskHeader"
import { CheckmarkControl } from "../common/CheckmarkControl"
+import McpResponseDisplay from "../mcp/McpResponseDisplay"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -46,62 +47,87 @@ interface ChatRowProps {
interface ChatRowContentProps extends Omit {}
-const ChatRow = memo(
- (props: ChatRowProps) => {
- const { isLast, onHeightChange, message, lastModifiedMessage } = props
- // Store the previous height to compare with the current height
- // This allows us to detect changes without causing re-renders
- const prevHeightRef = useRef(0)
-
- // NOTE: for tools that are interrupted and not responded to (approved or rejected), there won't be a checkpoint hash
- let shouldShowCheckpoints =
- message.lastCheckpointHash != null &&
- (message.say === "tool" ||
- message.ask === "tool" ||
- message.say === "command" ||
- message.ask === "command" ||
- // message.say === "completion_result" ||
- // message.ask === "completion_result" ||
- message.say === "use_mcp_server" ||
- message.ask === "use_mcp_server")
-
- if (shouldShowCheckpoints && isLast) {
- shouldShowCheckpoints =
- lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
- }
-
- const [chatrow, { height }] = useSize(
-
-
- {shouldShowCheckpoints && }
- ,
- )
-
- useEffect(() => {
- // used for partials, command output, etc.
- // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
- const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
- // height starts off at Infinity
- if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
- if (!isInitialRender) {
- onHeightChange(height > prevHeightRef.current)
- }
- prevHeightRef.current = height
- }
- }, [height, isLast, onHeightChange, message])
-
- // we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered
- return chatrow
- },
- // memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
- deepEqual,
+export const ProgressIndicator = () => (
+
)
+const Markdown = memo(({ markdown }: { markdown?: string }) => {
+ return (
+
+
+
+ )
+})
+
+const ChatRow = memo((props: ChatRowProps) => {
+ const { isLast, onHeightChange, message, lastModifiedMessage } = props
+ // Store the previous height to compare with the current height
+ // This allows us to detect changes without causing re-renders
+ const prevHeightRef = useRef(0)
+
+ // NOTE: for tools that are interrupted and not responded to (approved or rejected) there won't be a checkpoint hash
+ let shouldShowCheckpoints =
+ message.lastCheckpointHash != null &&
+ (message.say === "tool" ||
+ message.ask === "tool" ||
+ message.say === "command" ||
+ message.ask === "command" ||
+ // message.say === "completion_result" ||
+ // message.ask === "completion_result" ||
+ message.say === "use_mcp_server" ||
+ message.ask === "use_mcp_server")
+
+ if (shouldShowCheckpoints && isLast) {
+ shouldShowCheckpoints = lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
+ }
+
+ const [chatrow, { height }] = useSize(
+
+
+ {shouldShowCheckpoints && }
+ ,
+ )
+
+ useEffect(() => {
+ // used for partials command output etc.
+ // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
+ const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
+ // height starts off at Infinity
+ if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
+ if (!isInitialRender) {
+ onHeightChange(height > prevHeightRef.current)
+ }
+ prevHeightRef.current = height
+ }
+ }, [height, isLast, onHeightChange, message])
+
+ // we cannot return null as virtuoso does not support it so we use a separate visibleMessages array to filter out messages that should not be rendered
+ return chatrow
+},
+// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
+deepEqual)
+
export default ChatRow
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
-
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
@@ -111,11 +137,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
}
return [undefined, undefined, undefined]
}, [message.text, message.say])
- // when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
+
+ // when resuming task last won't be api_req_failed but a resume_task message so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
const apiRequestFailedMessage =
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
? lastModifiedMessage?.text
: undefined
+
const isCommandExecuting =
isLast &&
(lastModifiedMessage?.ask === "command" || lastModifiedMessage?.say === "command") &&
@@ -367,12 +395,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
Cline wants to read this file:
- {/* */}
>
)
- // case "inspectSite":
- // const isInspecting =
- // isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
- // return (
- // <>
- //
- // {isInspecting ?
: toolIcon("inspect")}
- //
- // {message.type === "ask" ? (
- // <>Cline wants to inspect this website:>
- // ) : (
- // <>Cline is inspecting this website:>
- // )}
- //
- //
- //
- //
- //
- // >
- // )
default:
return null
}
@@ -570,10 +566,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{icon}
{title}
- {/* 0}
- /> */}
@@ -742,6 +732,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
+
+ {/* {apiProvider === "" && (
+
+
+
+ Uh-oh this could be a problem on end. We've been alerted and
+ will resolve this ASAP. You can also{" "}
+
+ contact us
+
+ .
+
+
+ )} */}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
@@ -759,39 +782,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
>
)}
-
- {/* {apiProvider === "" && (
-
-
-
- Uh-oh, this could be a problem on end. We've been alerted and
- will resolve this ASAP. You can also{" "}
-
- contact us
-
- .
-
-
- )} */}
>
)}
@@ -809,6 +799,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "api_req_finished":
return null // we should never see this message type
+ case "mcp_server_response":
+ return
case "text":
return (
@@ -825,7 +817,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
// marginBottom: 15,
cursor: "pointer",
color: "var(--vscode-descriptionForeground)",
-
+
fontStyle: "italic",
overflow: "hidden",
}}>
@@ -1101,28 +1093,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
>
)
- case "mcp_server_response":
- return (
- <>
-
- >
- )
default:
return (
<>
@@ -1174,7 +1144,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "completion_result":
if (message.text) {
- // FIXME: is this ever even used?
const hasChanges = message.text.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
const text = hasChanges ? message.text.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
return (
@@ -1247,32 +1216,3 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
}
}
}
-
-export const ProgressIndicator = () => (
-
-)
-
-const Markdown = memo(({ markdown }: { markdown?: string }) => {
- return (
-
-
-
- )
-})
diff --git a/webview-ui/src/components/mcp/LinkPreview.tsx b/webview-ui/src/components/mcp/LinkPreview.tsx
new file mode 100644
index 0000000000..1f017df956
--- /dev/null
+++ b/webview-ui/src/components/mcp/LinkPreview.tsx
@@ -0,0 +1,188 @@
+import React, { useEffect, useState } from "react"
+import { vscode } from "../../utils/vscode"
+import DOMPurify from 'dompurify';
+
+interface OpenGraphData {
+ title?: string
+ description?: string
+ image?: string
+ url?: string
+ siteName?: string
+ type?: string
+}
+
+interface LinkPreviewProps {
+ url: string
+}
+
+const LinkPreview: React.FC = ({ url }) => {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [ogData, setOgData] = useState(null)
+
+ useEffect(() => {
+ const fetchOpenGraphData = async () => {
+ try {
+ setLoading(true)
+
+ // Send a message to the extension to fetch Open Graph data
+ vscode.postMessage({
+ type: "fetchOpenGraphData",
+ text: url,
+ })
+
+ // Set up a listener for the response
+ const messageListener = (event: MessageEvent) => {
+ const message = event.data
+ if (message.type === "openGraphData" && message.url === url) {
+ setOgData(message.openGraphData)
+ setLoading(false)
+ window.removeEventListener("message", messageListener)
+ }
+ }
+
+ window.addEventListener("message", messageListener)
+
+ // Clean up the listener if the component unmounts
+ return () => {
+ window.removeEventListener("message", messageListener)
+ }
+ } catch (err) {
+ setError("Failed to fetch preview data")
+ setLoading(false)
+ }
+ }
+
+ // Fetch Open Graph data immediately when component mounts
+ fetchOpenGraphData()
+ }, [url])
+
+ // Fallback display while loading
+ if (loading) {
+ return (
+
+
+
+ Loading preview for {new URL(url).hostname}...
+
+ )
+ }
+
+ // Create a fallback object if ogData is null
+ const data = ogData || {
+ title: new URL(url).hostname,
+ description: "No description available",
+ siteName: new URL(url).hostname,
+ url: url,
+ }
+
+ // Render the Open Graph preview
+ return (
+ {
+ vscode.postMessage({
+ type: "openInBrowser",
+ url: DOMPurify.sanitize(url),
+ })
+ }}>
+ {data.image && (
+
+
+
+ )}
+
+
+
+ {data.title || "No title"}
+
+
+
+ {data.siteName || new URL(url).hostname}
+
+
+
+ {data.description || "No description available"}
+
+
+
+ )
+}
+
+export default LinkPreview
diff --git a/webview-ui/src/components/mcp/McpResponseDisplay.tsx b/webview-ui/src/components/mcp/McpResponseDisplay.tsx
new file mode 100644
index 0000000000..6bf0441685
--- /dev/null
+++ b/webview-ui/src/components/mcp/McpResponseDisplay.tsx
@@ -0,0 +1,460 @@
+import React, { useEffect, useState, useCallback } from "react"
+import { vscode } from "../../utils/vscode"
+import LinkPreview from "./LinkPreview"
+import styled from "styled-components"
+import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
+import DOMPurify from 'dompurify';
+
+// We'll use the backend isImageUrl function for HEAD requests
+// This is a client-side fallback for data URLs and obvious image extensions
+const isImageUrlSync = (str: string): boolean => {
+ // Check for data URLs which are definitely images
+ if (str.startsWith("data:image/")) {
+ return true
+ }
+
+ // Check for common image file extensions
+ return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
+}
+
+export const isUrl = (str: string): boolean => {
+ // Basic URL validation
+ const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
+ return urlPattern.test(str)
+}
+
+// Function to check if a URL is an image using HEAD request
+export const checkIfImageUrl = async (url: string): Promise => {
+ // For data URLs, we can check synchronously
+ if (url.startsWith("data:image/")) {
+ return true
+ }
+
+ // For http/https URLs, we need to send a message to the extension
+ if (url.startsWith("http")) {
+ try {
+ // Create a promise that will resolve when we get a response
+ return new Promise((resolve) => {
+ // Set up a one-time listener for the response
+ const messageListener = (event: MessageEvent) => {
+ const message = event.data
+ if (message.type === "isImageUrlResult" && message.url === url) {
+ window.removeEventListener("message", messageListener)
+ resolve(message.isImage)
+ }
+ }
+
+ window.addEventListener("message", messageListener)
+
+ // Send the request to the extension
+ vscode.postMessage({
+ type: "checkIsImageUrl",
+ text: url,
+ })
+
+ // Set a timeout to avoid hanging indefinitely
+ setTimeout(() => {
+ window.removeEventListener("message", messageListener)
+ // Fall back to extension check
+ resolve(isImageUrlSync(url))
+ }, 3000)
+ })
+ } catch (error) {
+ console.error("Error checking if URL is an image:", error)
+ return isImageUrlSync(url)
+ }
+ }
+
+ // Fall back to extension check for other URLs
+ return isImageUrlSync(url)
+}
+
+// No longer needed as our regex directly extracts the URL part
+
+// Helper to ensure URL is in a format that can be opened
+export const formatUrlForOpening = (url: string): string => {
+ // If it's a data URI, return as is
+ if (url.startsWith("data:image/")) {
+ return url
+ }
+
+ // If it's a regular URL but doesn't have a protocol, add https://
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ return `https://${url}`
+ }
+
+ return url
+}
+
+// Find all URLs (both image and regular) in an object
+export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
+ const imageUrls: string[] = []
+ const regularUrls: string[] = []
+ const pendingChecks: Promise[] = []
+
+ if (typeof obj === "object" && obj !== null) {
+ for (const value of Object.values(obj)) {
+ if (typeof value === "string") {
+ // First check with synchronous method
+ if (isImageUrlSync(value)) {
+ imageUrls.push(value)
+ } else if (isUrl(value)) {
+ // For URLs that don't obviously look like images, we'll check asynchronously
+ const checkPromise = checkIfImageUrl(value).then((isImage) => {
+ if (isImage) {
+ imageUrls.push(value)
+ } else {
+ regularUrls.push(value)
+ }
+ })
+ pendingChecks.push(checkPromise)
+ }
+ } else if (typeof value === "object") {
+ const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
+ imageUrls.push(...nestedUrls.imageUrls)
+ regularUrls.push(...nestedUrls.regularUrls)
+ })
+ pendingChecks.push(nestedUrlsPromise)
+ }
+ }
+ }
+
+ // Wait for all async checks to complete
+ await Promise.all(pendingChecks)
+
+ return { imageUrls, regularUrls }
+}
+
+// Extract URLs from text using regex
+export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
+ const imageUrls: string[] = []
+ const regularUrls: string[] = []
+ const pendingChecks: Promise[] = []
+
+ // Match URLs with image: prefix and extract just the URL part
+ const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
+ if (imageMatches) {
+ // Extract just the URL part from matches with image: prefix
+ const extractedUrls = imageMatches
+ .map((match) => {
+ const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
+ return urlMatch ? urlMatch[1] : null
+ })
+ .filter(Boolean) as string[]
+
+ imageUrls.push(...extractedUrls)
+ }
+
+ // Match all URLs (including those that might be in the middle of paragraphs)
+ const urlMatches = text.match(/https?:\/\/[^\s]+/g)
+ if (urlMatches) {
+ // Filter out URLs that are already in imageUrls
+ const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
+
+ // Check each URL to see if it's an image
+ for (const url of filteredUrls) {
+ // First check with synchronous method
+ if (isImageUrlSync(url)) {
+ imageUrls.push(url)
+ } else {
+ // For URLs that don't obviously look like images, we'll check asynchronously
+ const checkPromise = checkIfImageUrl(url).then((isImage) => {
+ if (isImage) {
+ imageUrls.push(url)
+ } else {
+ regularUrls.push(url)
+ }
+ })
+ pendingChecks.push(checkPromise)
+ }
+ }
+ }
+
+ // Wait for all async checks to complete
+ await Promise.all(pendingChecks)
+
+ return { imageUrls, regularUrls }
+}
+
+const ResponseHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 9px 10px;
+ color: var(--vscode-descriptionForeground);
+ cursor: pointer;
+ user-select: none;
+ border-bottom: 1px dashed var(--vscode-editorGroup-border);
+ margin-bottom: 8px;
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-right: 8px;
+ }
+`
+
+const ToggleSwitch = styled.div`
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: var(--vscode-descriptionForeground);
+
+ .toggle-label {
+ margin-right: 8px;
+ }
+
+ .toggle-container {
+ position: relative;
+ width: 40px;
+ height: 20px;
+ background-color: var(--vscode-button-secondaryBackground);
+ border-radius: 10px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+ }
+
+ .toggle-container.active {
+ background-color: var(--vscode-button-background);
+ }
+
+ .toggle-handle {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 16px;
+ height: 16px;
+ background-color: var(--vscode-button-foreground);
+ border-radius: 50%;
+ transition: transform 0.3s;
+ }
+
+ .toggle-container.active .toggle-handle {
+ transform: translateX(20px);
+ }
+`
+
+const ResponseContainer = styled.div`
+ position: relative;
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: var(--vscode-editor-font-size, 12px);
+ background-color: ${CODE_BLOCK_BG_COLOR};
+ color: var(--vscode-editor-foreground, #d4d4d4);
+ border-radius: 3px;
+ border: 1px solid var(--vscode-editorGroup-border);
+ overflow: hidden;
+
+ .response-content {
+ overflow-x: auto;
+ overflow-y: hidden;
+ max-width: 100%;
+ padding: 10px;
+ }
+`
+
+// Style for URL text to ensure proper wrapping
+const UrlText = styled.div`
+ white-space: pre-wrap;
+ word-break: break-all;
+ overflow-wrap: break-word;
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: var(--vscode-editor-font-size, 12px);
+`
+
+interface McpResponseDisplayProps {
+ responseText: string
+}
+
+// Represents a URL found in the text with its position and metadata
+interface UrlMatch {
+ url: string // The actual URL
+ fullMatch: string // The full matched text (including any prefix like "image:")
+ index: number // Position in the text
+ isImage: boolean // Whether this URL is an image
+ isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
+}
+
+const McpResponseDisplay: React.FC = ({ responseText }) => {
+ const [isLoading, setIsLoading] = useState(true)
+ const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
+ // Get saved preference from localStorage, default to 'rich'
+ const savedMode = localStorage.getItem("mcpDisplayMode")
+ return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
+ })
+ const [urlMatches, setUrlMatches] = useState([])
+
+ const toggleDisplayMode = useCallback(() => {
+ const newMode = displayMode === "rich" ? "plain" : "rich"
+ setDisplayMode(newMode)
+ localStorage.setItem("mcpDisplayMode", newMode)
+ }, [displayMode])
+
+ // Find all URLs in the text and determine if they're images
+ useEffect(() => {
+ const processResponse = async () => {
+ setIsLoading(true)
+
+ try {
+ const text = responseText || ""
+ const matches: UrlMatch[] = []
+
+ const urlRegex = /https?:\/\/[^\s]+/g
+ let urlMatch: RegExpExecArray | null
+
+ while ((urlMatch = urlRegex.exec(text)) !== null) {
+ const url = urlMatch[0]
+ const fullMatch = url
+
+ matches.push({
+ url,
+ fullMatch,
+ index: urlMatch.index,
+ isImage: false, // Will check later
+ isProcessed: false,
+ })
+ }
+
+ // Check if URLs are images
+ for (const match of matches) {
+ match.isImage = await checkIfImageUrl(match.url)
+ }
+
+ // Sort by position in the text
+ matches.sort((a, b) => a.index - b.index)
+
+ setUrlMatches(matches)
+ } catch (error) {
+ console.error("Error processing MCP response:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ processResponse()
+ }, [responseText])
+
+ // Function to render content based on display mode
+ const renderContent = () => {
+ // For plain text mode, just show the text
+ if (displayMode === "plain" || isLoading) {
+ return {responseText}
+ }
+
+ // For rich display mode, show the text with embedded content
+ if (displayMode === "rich" && !isLoading) {
+ // Create an array of text segments and embedded content
+ const segments: JSX.Element[] = []
+ let lastIndex = 0
+ let segmentIndex = 0
+
+ // Reset the processed flag for all URLs
+ const processedUrls = new Set()
+
+ // Add the text before the first URL
+ if (urlMatches.length === 0) {
+ segments.push({responseText} )
+ } else {
+ for (let i = 0; i < urlMatches.length; i++) {
+ const match = urlMatches[i]
+ const { url, fullMatch, index } = match
+
+ // Add text segment before this URL
+ if (index > lastIndex) {
+ segments.push(
+ {responseText.substring(lastIndex, index)} ,
+ )
+ }
+
+ // Add the URL text itself
+ segments.push({fullMatch} )
+
+ // Calculate the end position of this URL in the text
+ const urlEndIndex = index + fullMatch.length
+
+ // Add embedded content after the URL
+ if (match.isImage) {
+ segments.push(
+
+
{
+ const formattedUrl = formatUrlForOpening(url)
+ vscode.postMessage({
+ type: "openInBrowser",
+ url: DOMPurify.sanitize(formattedUrl),
+ })
+ }}
+ />
+
,
+ )
+ } else if (!processedUrls.has(url)) {
+ // For non-image URLs, only show the preview once
+ segments.push(
+
+
+
,
+ )
+
+ // Mark this URL as processed
+ processedUrls.add(url)
+ }
+
+ // Update lastIndex for next segment
+ lastIndex = urlEndIndex
+ }
+
+ // Add any remaining text after the last URL
+ if (lastIndex < responseText.length) {
+ segments.push({responseText.substring(lastIndex)} )
+ }
+ }
+
+ return <>{segments}>
+ }
+
+ return null
+ }
+
+ try {
+ return (
+
+
+ Response
+
+ {displayMode === "rich" ? "Rich Display" : "Plain Text"}
+
+
+
+
+ {renderContent()}
+
+ )
+ } catch (error) {
+ console.error("Error parsing MCP response:", error)
+ return (
+
+
+ Response
+
+
+
Error parsing response:
+
{responseText}
+
+
+ )
+ }
+}
+
+export default McpResponseDisplay
From 4506eb39a63a1666a10a4b5b7d7e6d15b6941163 Mon Sep 17 00:00:00 2001
From: akfoster
Date: Tue, 25 Feb 2025 16:17:31 -0800
Subject: [PATCH 02/25] Add .clineignore to .gitignore (#1966)
* clineignor should be ignored by git
* changeset
---
.changeset/hot-boxes-applaud.md | 5 +++++
.gitignore | 4 +++-
2 files changed, 8 insertions(+), 1 deletion(-)
create mode 100644 .changeset/hot-boxes-applaud.md
diff --git a/.changeset/hot-boxes-applaud.md b/.changeset/hot-boxes-applaud.md
new file mode 100644
index 0000000000..577147c011
--- /dev/null
+++ b/.changeset/hot-boxes-applaud.md
@@ -0,0 +1,5 @@
+---
+"claude-dev": patch
+---
+
+.clineignore should be included in .gitignore
diff --git a/.gitignore b/.gitignore
index e6899656c2..f142a1a43a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,6 @@ tmp
.DS_Store
-pnpm-lock.yaml
\ No newline at end of file
+pnpm-lock.yaml
+
+.clineignore
\ No newline at end of file
From 916660a3907b6d43b9fa258ce134f92a69e817be Mon Sep 17 00:00:00 2001
From: Frostbourne
Date: Tue, 25 Feb 2025 16:18:17 -0800
Subject: [PATCH 03/25] [ENG-126] Migrate to Vite (#1876)
* Initial webview vite migration
* Make vite work
* Fix test running
* Enable HMR, disable vite chunking
* Silence type checking errors
* Vite doesn't use browserslist
* get rid of breaking css flag
* add doc to getHMRHtmlContent
* Make it work
* Changeset
* Add IS_DEV to env definitions
* Update tasks to include HMR
* Update CSP image rules
* prettier
* reintroduce IS_DEV in env
* add new deps to pkg lock
---------
Co-authored-by: Dennis Bartlett
---
.changeset/lazy-insects-report.md | 5 +
.github/dependabot.yml | 3 -
.vscode/tasks.json | 58 +-
.vscodeignore | 1 -
package.json | 2 +-
src/core/webview/ClineProvider.ts | 94 +-
webview-ui/.gitignore | 49 +-
webview-ui/eslint.config.js | 26 +
webview-ui/index.html | 12 +
webview-ui/matchMedia.js | 16 -
webview-ui/package-lock.json | 17487 ++--------------
webview-ui/package.json | 80 +-
webview-ui/public/index.html | 38 -
webview-ui/public/manifest.json | 25 -
webview-ui/public/robots.txt | 3 -
webview-ui/scripts/build-react-no-split.js | 134 -
webview-ui/setupTests.js | 2 -
webview-ui/src/components/chat/ChatRow.tsx | 93 +-
webview-ui/src/components/mcp/LinkPreview.tsx | 2 +-
.../src/components/mcp/McpResponseDisplay.tsx | 2 +-
webview-ui/src/index.tsx | 18 -
webview-ui/src/main.tsx | 12 +
webview-ui/src/react-app-env.d.ts | 1 -
webview-ui/src/reportWebVitals.ts | 15 -
webview-ui/src/setupTests.ts | 22 +-
webview-ui/src/vite-env.d.ts | 1 +
webview-ui/tsconfig.app.json | 25 +
webview-ui/tsconfig.json | 21 +-
webview-ui/tsconfig.node.json | 24 +
webview-ui/vite.config.js | 9 -
webview-ui/vite.config.ts | 43 +
31 files changed, 2056 insertions(+), 16267 deletions(-)
create mode 100644 .changeset/lazy-insects-report.md
create mode 100644 webview-ui/eslint.config.js
create mode 100644 webview-ui/index.html
delete mode 100644 webview-ui/matchMedia.js
delete mode 100644 webview-ui/public/index.html
delete mode 100644 webview-ui/public/manifest.json
delete mode 100644 webview-ui/public/robots.txt
delete mode 100644 webview-ui/scripts/build-react-no-split.js
delete mode 100644 webview-ui/setupTests.js
delete mode 100644 webview-ui/src/index.tsx
create mode 100644 webview-ui/src/main.tsx
delete mode 100644 webview-ui/src/react-app-env.d.ts
delete mode 100644 webview-ui/src/reportWebVitals.ts
create mode 100644 webview-ui/src/vite-env.d.ts
create mode 100644 webview-ui/tsconfig.app.json
create mode 100644 webview-ui/tsconfig.node.json
delete mode 100644 webview-ui/vite.config.js
create mode 100644 webview-ui/vite.config.ts
diff --git a/.changeset/lazy-insects-report.md b/.changeset/lazy-insects-report.md
new file mode 100644
index 0000000000..4ecd09a8ce
--- /dev/null
+++ b/.changeset/lazy-insects-report.md
@@ -0,0 +1,5 @@
+---
+"claude-dev": patch
+---
+
+Migrate webview from CRA to Vite
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index cff7f6ddd0..53c6b96b4b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -28,10 +28,7 @@ updates:
patterns:
- "*"
ignore:
- # Ignore CRA and related packages that often have false positives
- - dependency-name: "react-scripts"
- dependency-name: "@testing-library/*"
- - dependency-name: "web-vitals"
- dependency-name: "*"
update-types:
- "version-update:semver-major"
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index bb1e2b8999..84ebaf3d02 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -5,7 +5,7 @@
"tasks": [
{
"label": "watch",
- "dependsOn": ["npm: build:webview", "npm: watch:tsc", "npm: watch:esbuild"],
+ "dependsOn": ["npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"presentation": {
"reveal": "never"
},
@@ -23,7 +23,42 @@
"label": "npm: build:webview",
"presentation": {
"group": "watch",
- "reveal": "never"
+ "reveal": "never",
+ "close": true
+ },
+ "options": {
+ "env": {
+ "IS_DEV": "true"
+ }
+ }
+ },
+ {
+ "type": "npm",
+ "script": "dev:webview",
+ "group": "build",
+ "problemMatcher": [
+ {
+ "pattern": [
+ {
+ "regexp": ".",
+ "file": 1,
+ "location": 2,
+ "message": 3
+ }
+ ],
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": ".",
+ "endsPattern": "."
+ }
+ }
+ ],
+ "isBackground": true,
+ "label": "npm: dev:webview",
+ "presentation": {
+ "group": "watch",
+ "reveal": "never",
+ "close": true
},
"options": {
"env": {
@@ -40,7 +75,8 @@
"label": "npm: watch:esbuild",
"presentation": {
"group": "watch",
- "reveal": "never"
+ "reveal": "never",
+ "close": true
}
},
{
@@ -52,7 +88,8 @@
"label": "npm: watch:tsc",
"presentation": {
"group": "watch",
- "reveal": "never"
+ "reveal": "never",
+ "close": true
}
},
{
@@ -70,6 +107,19 @@
"label": "tasks: watch-tests",
"dependsOn": ["npm: watch", "npm: watch-tests"],
"problemMatcher": []
+ },
+ {
+ "label": "stop",
+ "command": "echo ${input:terminate}",
+ "type": "shell"
+ }
+ ],
+ "inputs": [
+ {
+ "id": "terminate",
+ "type": "command",
+ "command": "workbench.action.tasks.terminate",
+ "args": "terminateAll"
}
]
}
diff --git a/.vscodeignore b/.vscodeignore
index 3e7e885a00..b9c70a4fbf 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -23,7 +23,6 @@ demo.gif
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
webview-ui/src/**
webview-ui/public/**
-webview-ui/scripts/**
webview-ui/index.html
webview-ui/README.md
webview-ui/package.json
diff --git a/package.json b/package.json
index 9fa64f199a..6d59e81c4e 100644
--- a/package.json
+++ b/package.json
@@ -206,7 +206,7 @@
"format:fix": "prettier . --write",
"test": "vscode-test",
"install:all": "npm install && cd webview-ui && npm install",
- "start:webview": "cd webview-ui && npm run start",
+ "dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
"test:webview": "cd webview-ui && npm run test",
"publish:marketplace": "vsce publish && ovsx publish",
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 5c486c1521..e0d8121763 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -178,11 +178,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
}
- resolveWebviewView(
- webviewView: vscode.WebviewView | vscode.WebviewPanel,
- //context: vscode.WebviewViewResolveContext, used to recreate a deallocated webview, but we don't need this since we use retainContextWhenHidden
- //token: vscode.CancellationToken
- ): void | Thenable {
+ async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
this.outputChannel.appendLine("Resolving webview view")
this.view = webviewView
@@ -191,7 +187,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
}
- webviewView.webview.html = this.getHtmlContent(webviewView.webview)
+
+ webviewView.webview.html =
+ this.context.extensionMode === vscode.ExtensionMode.Development
+ ? await this.getHMRHtmlContent(webviewView.webview)
+ : this.getHtmlContent(webviewView.webview)
// Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is received
@@ -342,9 +342,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
- const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "css", "main.css"])
+ const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
// The JS file from the React build output
- const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "js", "main.js"])
+ const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
@@ -389,18 +389,92 @@ export class ClineProvider implements vscode.WebviewViewProvider {
-
+
Cline
You need to enable JavaScript to run this app.
-
+