mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 480a536214 | |||
| 7859272790 | |||
| 0871e72f6a | |||
| 619321b33d | |||
| 8ae5ee6bf3 | |||
| 5ebe30c70f | |||
| f0ea929ba3 | |||
| a4c526702e | |||
| 713dcf2750 | |||
| f48df1c513 | |||
| 1d27ee5542 | |||
| 4b359a9740 | |||
| b69730ef6f | |||
| 94ee90f786 | |||
| 971813fd0e | |||
| 8584abaaba | |||
| fb0cf74595 | |||
| 0291f834cf | |||
| 9a6a2e3430 | |||
| 6cdc60504c | |||
| 74f4de4cf9 | |||
| bff0e86e65 | |||
| 06e2b7af57 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Migrate webview from CRA to Vite
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add rich MCP responses with images and link previews
|
||||
@@ -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"
|
||||
|
||||
Vendored
+54
-4
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+24
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.4.6",
|
||||
"version": "3.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.4.6",
|
||||
"version": "3.4.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
@@ -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",
|
||||
@@ -11047,6 +11048,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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -176,11 +177,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<unknown>, used to recreate a deallocated webview, but we don't need this since we use retainContextWhenHidden
|
||||
//token: vscode.CancellationToken
|
||||
): void | Thenable<void> {
|
||||
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
|
||||
this.outputChannel.appendLine("Resolving webview view")
|
||||
this.view = webviewView
|
||||
|
||||
@@ -189,7 +186,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
|
||||
@@ -320,9 +321,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
|
||||
@@ -367,18 +368,92 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}';">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
|
||||
const localPort = 25463
|
||||
const localServerUrl = `localhost:${localPort}`
|
||||
|
||||
// Check if local dev server is running.
|
||||
try {
|
||||
await axios.get(`http://${localServerUrl}`)
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
|
||||
)
|
||||
|
||||
return this.getHtmlContent(webview)
|
||||
}
|
||||
|
||||
const nonce = getNonce()
|
||||
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
|
||||
const codiconsUri = getUri(webview, this.context.extensionUri, [
|
||||
"node_modules",
|
||||
"@vscode",
|
||||
"codicons",
|
||||
"dist",
|
||||
"codicon.css",
|
||||
])
|
||||
|
||||
const scriptEntrypoint = "src/main.tsx"
|
||||
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
|
||||
|
||||
const reactRefresh = /*html*/ `
|
||||
<script nonce="${nonce}" type="module">
|
||||
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
`
|
||||
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`font-src ${webview.cspSource}`,
|
||||
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${webview.cspSource} https: data:`,
|
||||
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
]
|
||||
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Roo Code</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
${reactRefresh}
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an event listener to listen for messages passed from the webview context and
|
||||
* executes code based on the message that is received.
|
||||
@@ -635,6 +710,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
|
||||
@@ -1906,6 +1992,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() {
|
||||
|
||||
@@ -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<OpenGraphData> {
|
||||
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<boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export interface ExtensionMessage {
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "mcpDownloadDetails"
|
||||
| "commitSearchResults"
|
||||
| "openGraphData"
|
||||
| "isImageUrlResult"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
@@ -54,6 +56,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"
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WebviewMessage {
|
||||
| "requestOllamaModels"
|
||||
| "requestLmStudioModels"
|
||||
| "openImage"
|
||||
| "openInBrowser"
|
||||
| "openFile"
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
@@ -50,6 +51,9 @@ export interface WebviewMessage {
|
||||
| "searchCommits"
|
||||
| "showMcpView"
|
||||
| "fetchLatestMcpServersFromHub"
|
||||
| "updateMcpTimeout"
|
||||
| "fetchOpenGraphData"
|
||||
| "checkIsImageUrl"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
@@ -68,6 +72,9 @@ export interface WebviewMessage {
|
||||
serverName?: string
|
||||
toolName?: string
|
||||
autoApprove?: boolean
|
||||
|
||||
// For openInBrowser
|
||||
url?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
+29
-20
@@ -1,23 +1,32 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
build
|
||||
*.local
|
||||
coverage
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import js from "@eslint/js"
|
||||
import globals from "globals"
|
||||
import reactHooks from "eslint-plugin-react-hooks"
|
||||
import reactRefresh from "eslint-plugin-react-refresh"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["build"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cline Webview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
// "Official" jest workaround for mocking window.matchMedia()
|
||||
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
Generated
+1645
-15846
File diff suppressed because it is too large
Load Diff
+35
-47
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"name": "webview-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest dev"
|
||||
},
|
||||
"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",
|
||||
@@ -14,54 +25,31 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.7.13",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"rewire": "^7.0.0",
|
||||
"styled-components": "^6.1.13",
|
||||
"typescript": "^5.7.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "node ./scripts/build-react-no-split.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest dev",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"react-use": "^17.6.0",
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"rehype-highlight": "^7.0.1",
|
||||
"styled-components": "^6.1.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^15.0.6",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^20.x",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/vscode-webview": "^1.57.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"vitest": "^2.1.9"
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.1.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
--></body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* A script that overrides some of the create-react-app build script configurations
|
||||
* in order to disable code splitting/chunking and rename the output build files so
|
||||
* they have no hash. (Reference: https://mtm.dev/disable-code-splitting-create-react-app).
|
||||
*
|
||||
* This is crucial for getting React webview code to run because VS Code expects a
|
||||
* single (consistently named) JavaScript and CSS file when configuring webviews.
|
||||
*/
|
||||
|
||||
const rewire = require("rewire")
|
||||
const defaults = rewire("react-scripts/scripts/build.js")
|
||||
const config = defaults.__get__("config")
|
||||
const webpack = require("webpack")
|
||||
|
||||
/* Modifying Webpack Configuration for 'shared' dir
|
||||
This section uses Rewire to modify Create React App's webpack configuration without ejecting. Rewire allows us to inject and alter the internal build scripts of CRA at runtime. This allows us to maintain a flexible project structure that keeps shared code outside the webview-ui/src directory, while still adhering to CRA's security model that typically restricts imports to within src/.
|
||||
1. Uses the ModuleScopePlugin to whitelist files from the shared directory, allowing them to be imported despite being outside src/. (see: https://stackoverflow.com/questions/44114436/the-create-react-app-imports-restriction-outside-of-src-directory/58321458#58321458)
|
||||
2. Modifies the TypeScript rule to include the shared directory in compilation. This essentially transpiles and includes the ts files in shared dir in the output main.js file.
|
||||
Before, we would just import types from shared dir and specifying include (and alias to have cleaner paths) in tsconfig.json was enough. But now that we are creating values (i.e. models in api.ts) to import into the react app, we must also include these files in the webpack resolution.
|
||||
- Imports from the shared directory must use full paths relative to the src directory, without file extensions.
|
||||
- Example: import { someFunction } from '../../src/shared/utils/helpers'
|
||||
*/
|
||||
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin")
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
// Get all files in the shared directory
|
||||
const sharedDir = path.resolve(__dirname, "..", "..", "src", "shared")
|
||||
|
||||
function getAllFiles(dir) {
|
||||
let files = []
|
||||
fs.readdirSync(dir).forEach((file) => {
|
||||
const filePath = path.join(dir, file)
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
files = files.concat(getAllFiles(filePath))
|
||||
} else {
|
||||
// Skip test files
|
||||
if (!file.endsWith(".test.ts")) {
|
||||
const withoutExtension = path.join(dir, path.parse(file).name)
|
||||
files.push(withoutExtension)
|
||||
}
|
||||
}
|
||||
})
|
||||
return files
|
||||
}
|
||||
const sharedFiles = getAllFiles(sharedDir)
|
||||
// config.resolve.plugins = config.resolve.plugins.filter((plugin) => !(plugin instanceof ModuleScopePlugin))
|
||||
// Instead of excluding the whole ModuleScopePlugin, we just whitelist specific files that can be imported from outside src.
|
||||
config.resolve.plugins.forEach((plugin) => {
|
||||
if (plugin instanceof ModuleScopePlugin) {
|
||||
console.log("Whitelisting shared files: ", sharedFiles)
|
||||
sharedFiles.forEach((file) => plugin.allowedFiles.add(file))
|
||||
}
|
||||
})
|
||||
/*
|
||||
Webpack configuration
|
||||
|
||||
Webpack is a module bundler for JavaScript applications. It processes your project files, resolving dependencies and generating a deployable production build.
|
||||
The webpack config is an object that tells webpack how to process and bundle your code. It defines entry points, output settings, and how to handle different file types.
|
||||
This config.module section of the webpack config deals with how different file types (modules) should be treated.
|
||||
config.module.rules:
|
||||
Rules define how module files should be processed. Each rule can:
|
||||
- Specify which files to process (test)
|
||||
When webpack "processes" a file, it performs several operations:
|
||||
1. Reads the file
|
||||
2. Parses its content and analyzes dependencies
|
||||
3. Applies transformations (e.g., converting TypeScript to JavaScript)
|
||||
4. Potentially modifies the code (e.g., applying polyfills)
|
||||
5. Includes the processed file in the final bundle
|
||||
By specifying which files to process, we're telling webpack which files should go through this pipeline and be included in our application bundle. Files that aren't processed are ignored by webpack.
|
||||
In our case, we're ensuring that TypeScript files in our shared directory are processed, allowing us to use them in our application.
|
||||
- Define which folders to include or exclude
|
||||
- Set which loaders to use for transformation
|
||||
A loader transforms certain types of files into valid modules that webpack can process. For example, the TypeScript loader converts .ts files into JavaScript that webpack can understand.
|
||||
By modifying these rules, we can change how webpack processes different files in our project, allowing us to include files from outside the standard src directory.
|
||||
|
||||
Why we need to modify the webpack config
|
||||
|
||||
Create React App (CRA) is designed to only process files within the src directory for security reasons. (CRA limits processing to the src directory to prevent accidental inclusion of sensitive files, reduce the attack surface, and ensure predictable builds, enhancing overall project security and consistency. Therefore it's essential that if you do include files outside src, you do so explicitly.)
|
||||
To use files from the shared directory, we need to:
|
||||
1. Modify ModuleScopePlugin to allow imports from the shared directory.
|
||||
2. Update the TypeScript loader rule to process TypeScript files from the shared directory.
|
||||
These changes tell webpack it's okay to import from the shared directory and ensure that TypeScript files in this directory are properly converted to JavaScript.
|
||||
|
||||
Modify webpack configuration to process TypeScript files from shared directory
|
||||
|
||||
This code modifies the webpack configuration to allow processing of TypeScript files from our shared directory, which is outside the standard src folder.
|
||||
1. config.module.rules[1]: In Create React App's webpack config, the second rule (index 1) typically contains the rules for processing JavaScript and TypeScript files.
|
||||
2. .oneOf: This array contains a list of loaders, and webpack will use the first matching loader for each file. We iterate through these to find the TypeScript loader.
|
||||
3. We check each rule to see if it applies to TypeScript files by looking for 'ts|tsx' in the test regex.
|
||||
4. When we find the TypeScript rule, we add our shared directory to its 'include' array. This tells webpack to also process TypeScript files from the shared directory.
|
||||
Note: This code assumes a specific structure in the CRA webpack config. If CRA updates its config structure in future versions, this code might need to be adjusted.
|
||||
*/
|
||||
config.module.rules[1].oneOf.forEach((rule) => {
|
||||
if (rule.test && rule.test.toString().includes("ts|tsx")) {
|
||||
// rule.include is path to src by default, but we can update rule.include to be an array as it matches an expected schema by react-scripts
|
||||
rule.include = [rule.include, sharedDir].filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
// Force all code into a single bundle for VS Code webview compatibility.
|
||||
// This is necessary for:
|
||||
// 1. Mermaid.js to work properly (prevents async chunk loading)
|
||||
// 2. Consistent CSP nonce handling (single bundle = single nonce)
|
||||
config.optimization = {
|
||||
...config.optimization,
|
||||
splitChunks: {
|
||||
cacheGroups: {
|
||||
default: false,
|
||||
},
|
||||
name: "main", // Forces all chunks (dynamic import() calls, for example those used by Mermaid) into one bundle - this is what actually prevents code splitting
|
||||
},
|
||||
runtimeChunk: false,
|
||||
}
|
||||
|
||||
// Ensure all chunks are named 'main' to match our CSP nonce setup
|
||||
config.output = {
|
||||
...config.output,
|
||||
filename: "static/js/[name].js",
|
||||
}
|
||||
|
||||
// Adjust build environment variables for dev/debug builds.
|
||||
config.plugins[4] = new webpack.DefinePlugin({
|
||||
"process.env": {
|
||||
...config.plugins[4].definitions["process.env"],
|
||||
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
|
||||
IS_DEV: JSON.stringify(process.env.IS_DEV),
|
||||
},
|
||||
})
|
||||
|
||||
// Rename main.{hash}.css to main.css
|
||||
config.plugins[5].options.filename = "static/css/[name].css"
|
||||
config.plugins[5].options.moduleFilename = () => "static/css/main.css"
|
||||
@@ -1,2 +0,0 @@
|
||||
import "@testing-library/jest-dom"
|
||||
import "./matchMedia"
|
||||
@@ -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,6 +47,35 @@ interface ChatRowProps {
|
||||
|
||||
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
|
||||
|
||||
export const ProgressIndicator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
|
||||
<VSCodeProgressRing />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Markdown = memo(({ markdown }: { markdown?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
marginBottom: -15,
|
||||
marginTop: -15,
|
||||
}}>
|
||||
<MarkdownBlock markdown={markdown} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ChatRow = memo(
|
||||
(props: ChatRowProps) => {
|
||||
const { isLast, onHeightChange, message, lastModifiedMessage } = props
|
||||
@@ -53,7 +83,7 @@ const ChatRow = memo(
|
||||
// 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
|
||||
// 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" ||
|
||||
@@ -78,7 +108,7 @@ const ChatRow = memo(
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// used for partials, command output, etc.
|
||||
// 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
|
||||
@@ -90,7 +120,7 @@ const ChatRow = memo(
|
||||
}
|
||||
}, [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
|
||||
// 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
|
||||
@@ -101,7 +131,6 @@ 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 +140,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 +398,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
Cline wants to read this file:
|
||||
</span>
|
||||
</div>
|
||||
{/* <CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
@@ -498,32 +523,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
/>
|
||||
</>
|
||||
)
|
||||
// case "inspectSite":
|
||||
// const isInspecting =
|
||||
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
|
||||
// return (
|
||||
// <>
|
||||
// <div style={headerStyle}>
|
||||
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
|
||||
// <span style={{ fontWeight: "bold" }}>
|
||||
// {message.type === "ask" ? (
|
||||
// <>Cline wants to inspect this website:</>
|
||||
// ) : (
|
||||
// <>Cline is inspecting this website:</>
|
||||
// )}
|
||||
// </span>
|
||||
// </div>
|
||||
// <div
|
||||
// style={{
|
||||
// borderRadius: 3,
|
||||
// border: "1px solid var(--vscode-editorGroup-border)",
|
||||
// overflow: "hidden",
|
||||
// backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
// }}>
|
||||
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
|
||||
// </div>
|
||||
// </>
|
||||
// )
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -570,10 +569,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{/* <Terminal
|
||||
rawOutput={command + (output ? "\n" + output : "")}
|
||||
shouldAllowInput={!!isCommandExecuting && output.length > 0}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
@@ -640,7 +635,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
{useMcpServer.type === "access_mcp_resource" && (
|
||||
<McpResourceRow
|
||||
item={{
|
||||
// Use the matched resource/template details, with fallbacks
|
||||
...(findMatchingResourceOrTemplate(
|
||||
useMcpServer.uri || "",
|
||||
server?.resources,
|
||||
@@ -650,7 +644,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
mimeType: "",
|
||||
description: "",
|
||||
}),
|
||||
// Always use the actual URI from the request
|
||||
uri: useMcpServer.uri || "",
|
||||
}}
|
||||
/>
|
||||
@@ -742,6 +735,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
|
||||
|
||||
{/* {apiProvider === "" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
|
||||
color: "var(--vscode-editor-foreground)",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "3px",
|
||||
margin: "10px 0 0 0",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}></i>
|
||||
<span>
|
||||
Uh-oh this could be a problem on end. We've been alerted and
|
||||
will resolve this ASAP. You can also{" "}
|
||||
<a
|
||||
href=""
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
contact us
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
|
||||
<>
|
||||
<br />
|
||||
@@ -759,39 +785,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* {apiProvider === "" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
|
||||
color: "var(--vscode-editor-foreground)",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "3px",
|
||||
margin: "10px 0 0 0",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}></i>
|
||||
<span>
|
||||
Uh-oh, this could be a problem on end. We've been alerted and
|
||||
will resolve this ASAP. You can also{" "}
|
||||
<a
|
||||
href=""
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
contact us
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -809,6 +802,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 <McpResponseDisplay responseText={message.text || ""} />
|
||||
case "text":
|
||||
return (
|
||||
<div>
|
||||
@@ -1101,28 +1096,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "mcp_server_response":
|
||||
return (
|
||||
<>
|
||||
<div style={{ paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Response
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={message.text}
|
||||
language="json"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
@@ -1174,7 +1147,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 +1219,3 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ProgressIndicator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
|
||||
<VSCodeProgressRing />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Markdown = memo(({ markdown }: { markdown?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
marginBottom: -15,
|
||||
marginTop: -15,
|
||||
}}>
|
||||
<MarkdownBlock markdown={markdown} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<LinkPreviewProps> = ({ url }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ogData, setOgData] = useState<OpenGraphData | null>(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 (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
}}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {new URL(url).hostname}...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="link-preview"
|
||||
style={{
|
||||
display: "flex",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(data.image)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || new URL(url).hostname}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkPreview
|
||||
@@ -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<boolean> => {
|
||||
// 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<void>[] = []
|
||||
|
||||
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<void>[] = []
|
||||
|
||||
// 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<McpResponseDisplayProps> = ({ 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<UrlMatch[]>([])
|
||||
|
||||
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 <UrlText>{responseText}</UrlText>
|
||||
}
|
||||
|
||||
// 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<string>()
|
||||
|
||||
// Add the text before the first URL
|
||||
if (urlMatches.length === 0) {
|
||||
segments.push(<UrlText key={`segment-${segmentIndex}`}>{responseText}</UrlText>)
|
||||
} 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(
|
||||
<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex, index)}</UrlText>,
|
||||
)
|
||||
}
|
||||
|
||||
// Add the URL text itself
|
||||
segments.push(<UrlText key={`url-${segmentIndex++}`}>{fullMatch}</UrlText>)
|
||||
|
||||
// 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(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image for ${url}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formattedUrl = formatUrlForOpening(url)
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formattedUrl),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
} else if (!processedUrls.has(url)) {
|
||||
// For non-image URLs, only show the preview once
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// 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(<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex)}</UrlText>)
|
||||
}
|
||||
}
|
||||
|
||||
return <>{segments}</>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
<span className="header-title">Response</span>
|
||||
<ToggleSwitch>
|
||||
<span className="toggle-label">{displayMode === "rich" ? "Rich Display" : "Plain Text"}</span>
|
||||
<div className={`toggle-container ${displayMode === "rich" ? "active" : ""}`} onClick={toggleDisplayMode}>
|
||||
<div className="toggle-handle"></div>
|
||||
</div>
|
||||
</ToggleSwitch>
|
||||
</ResponseHeader>
|
||||
|
||||
<div className="response-content">{renderContent()}</div>
|
||||
</ResponseContainer>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error parsing MCP response:", error)
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
<span className="header-title">Response</span>
|
||||
</ResponseHeader>
|
||||
<div className="response-content">
|
||||
<div>Error parsing response:</div>
|
||||
<UrlText>{responseText}</UrlText>
|
||||
</div>
|
||||
</ResponseContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default McpResponseDisplay
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import "./index.css"
|
||||
import App from "./App"
|
||||
import reportWebVitals from "./reportWebVitals"
|
||||
import "../../node_modules/@vscode/codicons/dist/codicon.css"
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import "./index.css"
|
||||
import App from "./App.tsx"
|
||||
import "../../node_modules/@vscode/codicons/dist/codicon.css"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ReportHandler } from "web-vitals"
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry)
|
||||
getFID(onPerfEntry)
|
||||
getFCP(onPerfEntry)
|
||||
getLCP(onPerfEntry)
|
||||
getTTFB(onPerfEntry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default reportWebVitals
|
||||
@@ -1,5 +1,19 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import "@testing-library/jest-dom"
|
||||
import { vi } from "vitest"
|
||||
|
||||
// "Official" jest workaround for mocking window.matchMedia()
|
||||
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
@@ -1,21 +1,4 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "../src/shared"],
|
||||
"exclude": ["src/**/*.spec.ts", "setupTests.js", "matchMedia.js"]
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./setupTests.js"],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react-swc"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
},
|
||||
build: {
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: `assets/[name].js`,
|
||||
chunkFileNames: `assets/[name].js`,
|
||||
assetFileNames: `assets/[name].[ext]`,
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 100000,
|
||||
},
|
||||
server: {
|
||||
port: 25463,
|
||||
hmr: {
|
||||
host: "localhost",
|
||||
protocol: "ws",
|
||||
},
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: "*",
|
||||
allowedHeaders: "*",
|
||||
},
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
|
||||
IS_DEV: JSON.stringify(process.env.IS_DEV),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user