Compare commits

...
Author SHA1 Message Date
Elephant Lumps 370b774c14 changeset 2025-05-19 17:18:04 -07:00
Elephant Lumps 557c1549be centralize navigation message handling in the extension state context 2025-05-19 17:17:23 -07:00
Tomás Barreiro c535a5ec73 Fail the workflow on webview test errors (#3275)
* do not exit with status code 0 if tests fail

* fix broken tests

* use a data-testid to find the button

* Fix API options test
2025-05-19 23:22:49 +05:30
canvrno 644280bbb4 copyToClipboard protobus migration (#3615) 2025-05-19 10:37:34 -07:00
EvanandElephant Lumps 43357c1100 Migrate restartMcpServer protobus (#3606)
* migrate restartMcpServer

* changeset

* remove markdown file

* use stringRequest

* fix type

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-05-18 17:13:18 -07:00
canvrno eb19731843 taskCompletionViewChanges protobus migration (#3588) 2025-05-18 13:30:33 -07:00
31 changed files with 711 additions and 211 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Put all the navigation state and message handling and navigation functions in the extension state context instead of the app.tsx
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
taskCompletionViewChanges protobus migration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
copyToClipboard protobus migration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate restartMcpServer to protobus
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
cd webview-ui
# Ensure coverage dependency is installed
npm install --no-save @vitest/coverage-v8
npm run test:coverage > webview_coverage.txt 2>&1 || true
npm run test:coverage > webview_coverage.txt 2>&1
cd ..
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
+3
View File
@@ -8,6 +8,9 @@ import "common.proto";
// Service for file-related operations
service FileService {
// Copies text to clipboard
rpc copyToClipboard(StringRequest) returns (Empty);
// Opens a file in the editor
rpc openFile(StringRequest) returns (Empty);
+1
View File
@@ -11,6 +11,7 @@ service McpService {
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
rpc downloadMcp(StringRequest) returns (Empty);
rpc restartMcpServer(StringRequest) returns (McpServers);
}
message ToggleMcpServerRequest {
+2
View File
@@ -29,6 +29,8 @@ service TaskService {
rpc askResponse(AskResponseRequest) returns (Empty);
// Records task feedback (thumbs up/down)
rpc taskFeedback(StringRequest) returns (Empty);
// Shows task completion changes diff in a view
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
}
// Request message for creating a new task
@@ -0,0 +1,20 @@
import * as vscode from "vscode"
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
/**
* Copies text to the system clipboard
* @param controller The controller instance
* @param request The request containing the text to copy
* @returns Empty response
*/
export async function copyToClipboard(controller: Controller, request: StringRequest): Promise<Empty> {
try {
if (request.value) {
await vscode.env.clipboard.writeText(request.value)
}
} catch (error) {
console.error("Error copying to clipboard:", error)
}
return Empty.create()
}
-22
View File
@@ -341,12 +341,6 @@ export class Controller {
case "openMention":
openMention(message.text)
break
case "taskCompletionViewChanges": {
if (message.number) {
await this.task?.presentMultifileDiff(message.number, true)
}
break
}
case "accountLogoutClicked": {
await this.handleSignOut()
break
@@ -488,14 +482,6 @@ export class Controller {
this.refreshTotalTasksSize()
break
}
case "restartMcpServer": {
try {
await this.mcpHub?.restartConnection(message.text!)
} catch (error) {
console.error(`Failed to retry connection for ${message.text}:`, error)
}
break
}
case "deleteMcpServer": {
if (message.serverName) {
this.mcpHub?.deleteServer(message.serverName)
@@ -621,14 +607,6 @@ export class Controller {
break
}
case "copyToClipboard": {
try {
await vscode.env.clipboard.writeText(message.text || "")
} catch (error) {
console.error("Error copying to clipboard:", error)
}
break
}
case "updateTerminalConnectionTimeout": {
if (message.shellIntegrationTimeout !== undefined) {
const timeout = message.shellIntegrationTimeout
+2
View File
@@ -5,6 +5,7 @@
import { registerMethod } from "./index"
import { addRemoteMcpServer } from "./addRemoteMcpServer"
import { downloadMcp } from "./downloadMcp"
import { restartMcpServer } from "./restartMcpServer"
import { toggleMcpServer } from "./toggleMcpServer"
import { updateMcpTimeout } from "./updateMcpTimeout"
@@ -13,6 +14,7 @@ export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("addRemoteMcpServer", addRemoteMcpServer)
registerMethod("downloadMcp", downloadMcp)
registerMethod("restartMcpServer", restartMcpServer)
registerMethod("toggleMcpServer", toggleMcpServer)
registerMethod("updateMcpTimeout", updateMcpTimeout)
}
@@ -0,0 +1,24 @@
import type { McpServers } from "@shared/proto/mcp"
import type { Controller } from "../index"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { StringRequest } from "@/shared/proto/common"
/**
* Restarts an MCP server connection
* @param controller The controller instance
* @param request The request containing the server name
* @returns The updated list of MCP servers
*/
export async function restartMcpServer(controller: Controller, request: StringRequest): Promise<McpServers> {
try {
const mcpServers = await controller.mcpHub?.restartConnectionRPC(request.value)
// Convert from McpServer[] to ProtoMcpServer[] ensuring all required fields are set
const protoServers = convertMcpServersToProtoMcpServers(mcpServers)
return { mcpServers: protoServers }
} catch (error) {
console.error(`Failed to restart MCP server ${request.value}:`, error)
throw error
}
}
+2
View File
@@ -12,6 +12,7 @@ import { exportTaskWithId } from "./exportTaskWithId"
import { getTaskHistory } from "./getTaskHistory"
import { newTask } from "./newTask"
import { showTaskWithId } from "./showTaskWithId"
import { taskCompletionViewChanges } from "./taskCompletionViewChanges"
import { taskFeedback } from "./taskFeedback"
import { toggleTaskFavorite } from "./toggleTaskFavorite"
@@ -27,6 +28,7 @@ export function registerAllMethods(): void {
registerMethod("getTaskHistory", getTaskHistory)
registerMethod("newTask", newTask)
registerMethod("showTaskWithId", showTaskWithId)
registerMethod("taskCompletionViewChanges", taskCompletionViewChanges)
registerMethod("taskFeedback", taskFeedback)
registerMethod("toggleTaskFavorite", toggleTaskFavorite)
}
@@ -0,0 +1,21 @@
import { Controller } from ".."
import { Empty } from "../../../shared/proto/common"
import { Int64Request } from "../../../shared/proto/common"
/**
* Shows task completion changes in a diff view
* @param controller The controller instance
* @param request The request containing the timestamp of the message
* @returns Empty response
*/
export async function taskCompletionViewChanges(controller: Controller, request: Int64Request): Promise<Empty> {
try {
if (request.value && controller.task) {
await controller.task.presentMultifileDiff(request.value, true)
}
return Empty.create()
} catch (error) {
console.error("Error in taskCompletionViewChanges handler:", error)
throw error
}
}
+145
View File
@@ -103,6 +103,7 @@ export class McpHub {
getServers(): McpServer[] {
// Only return enabled servers
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
@@ -180,6 +181,120 @@ export class McpHub {
}
}
private async connectToServerRPC(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema>,
): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
try {
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
{
name: "Cline",
version: this.clientVersion,
},
{
capabilities: {},
},
)
let transport: StdioClientTransport | SSEClientTransport
if (config.transportType === "sse") {
transport = new SSEClientTransport(new URL(config.url), {})
} else {
transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...config.env,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
}
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
}
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
}
}
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: config.disabled,
},
client,
transport,
}
this.connections.push(connection)
if (config.transportType === "stdio") {
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = (transport as StdioClientTransport).stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
// Check if output contains INFO level log
const isInfoLog = !/\berror\b/i.test(output)
if (isInfoLog) {
// Log normal informational messages
console.info(`Server "${name}" info:`, output)
} else {
// Treat as error log
console.error(`Server "${name}" stderr:`, output)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
this.appendErrorMessage(connection, output)
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
}
// Connect
await client.connect(transport)
connection.server.status = "connected"
connection.server.error = ""
// Initial fetch of tools and resources
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
}
throw error
}
}
private async connectToServer(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema>,
@@ -494,6 +609,36 @@ export class McpHub {
this.fileWatchers.clear()
}
async restartConnectionRPC(serverName: string): Promise<McpServer[]> {
this.isConnecting = true
// Get existing connection and update its status
const connection = this.connections.find((conn) => conn.server.name === serverName)
const inMemoryConfig = connection?.server.config
if (inMemoryConfig) {
connection.server.status = "connecting"
connection.server.error = ""
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServerRPC(serverName, JSON.parse(inMemoryConfig))
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
}
}
this.isConnecting = false
const config = await this.readAndValidateMcpSettingsFile()
if (!config) {
throw new Error("Failed to read or validate MCP settings")
}
const serverOrder = Object.keys(config.mcpServers || {})
return this.getSortedMcpServers(serverOrder)
}
async restartConnection(serverName: string): Promise<void> {
this.isConnecting = true
-3
View File
@@ -20,12 +20,10 @@ export interface WebviewMessage {
| "showChatView"
| "refreshClineRules"
| "openMcpSettings"
| "restartMcpServer"
| "deleteMcpServer"
| "autoApprovalSettings"
| "browserRelaunchResult"
| "togglePlanActMode"
| "taskCompletionViewChanges"
| "openExtensionSettings"
| "requestVsCodeLmModels"
| "toggleToolAutoApprove"
@@ -55,7 +53,6 @@ export interface WebviewMessage {
| "toggleWindsurfRule"
| "toggleWorkflow"
| "deleteClineRule"
| "copyToClipboard"
| "updateTerminalConnectionTimeout"
| "setActiveQuote"
+9
View File
@@ -906,6 +906,15 @@ export const FileServiceDefinition = {
name: "FileService",
fullName: "cline.FileService",
methods: {
/** Copies text to clipboard */
copyToClipboard: {
name: "copyToClipboard",
requestType: StringRequest,
requestStream: false,
responseType: Empty,
responseStream: false,
options: {},
},
/** Opens a file in the editor */
openFile: {
name: "openFile",
+8
View File
@@ -1012,6 +1012,14 @@ export const McpServiceDefinition = {
responseStream: false,
options: {},
},
restartMcpServer: {
name: "restartMcpServer",
requestType: StringRequest,
requestStream: false,
responseType: McpServers,
responseStream: false,
options: {},
},
},
} as const
+10 -1
View File
@@ -6,7 +6,7 @@
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"
import { Empty, EmptyRequest, Metadata, StringArrayRequest, StringRequest } from "./common"
import { Empty, EmptyRequest, Int64Request, Metadata, StringArrayRequest, StringRequest } from "./common"
export const protobufPackage = "cline"
@@ -1187,6 +1187,15 @@ export const TaskServiceDefinition = {
responseStream: false,
options: {},
},
/** Shows task completion changes diff in a view */
taskCompletionViewChanges: {
name: "taskCompletionViewChanges",
requestType: Int64Request,
requestStream: false,
responseType: Empty,
responseStream: false,
options: {},
},
},
} as const
+6
View File
@@ -19,6 +19,7 @@ import { checkpointDiff } from "../core/controller/checkpoints/checkpointDiff"
import { checkpointRestore } from "../core/controller/checkpoints/checkpointRestore"
// File Service
import { copyToClipboard } from "../core/controller/file/copyToClipboard"
import { openFile } from "../core/controller/file/openFile"
import { openImage } from "../core/controller/file/openImage"
import { deleteRuleFile } from "../core/controller/file/deleteRuleFile"
@@ -33,6 +34,7 @@ import { toggleMcpServer } from "../core/controller/mcp/toggleMcpServer"
import { updateMcpTimeout } from "../core/controller/mcp/updateMcpTimeout"
import { addRemoteMcpServer } from "../core/controller/mcp/addRemoteMcpServer"
import { downloadMcp } from "../core/controller/mcp/downloadMcp"
import { restartMcpServer } from "../core/controller/mcp/restartMcpServer"
// Models Service
import { getOllamaModels } from "../core/controller/models/getOllamaModels"
@@ -64,6 +66,7 @@ import { deleteNonFavoritedTasks } from "../core/controller/task/deleteNonFavori
import { getTaskHistory } from "../core/controller/task/getTaskHistory"
import { askResponse } from "../core/controller/task/askResponse"
import { taskFeedback } from "../core/controller/task/taskFeedback"
import { taskCompletionViewChanges } from "../core/controller/task/taskCompletionViewChanges"
// Web Service
import { checkIsImageUrl } from "../core/controller/web/checkIsImageUrl"
@@ -98,6 +101,7 @@ export function addServices(
// File Service
server.addService(proto.cline.FileService.service, {
copyToClipboard: wrapper(copyToClipboard, controller),
openFile: wrapper(openFile, controller),
openImage: wrapper(openImage, controller),
deleteRuleFile: wrapper(deleteRuleFile, controller),
@@ -114,6 +118,7 @@ export function addServices(
updateMcpTimeout: wrapper(updateMcpTimeout, controller),
addRemoteMcpServer: wrapper(addRemoteMcpServer, controller),
downloadMcp: wrapper(downloadMcp, controller),
restartMcpServer: wrapper(restartMcpServer, controller),
})
// Models Service
@@ -153,6 +158,7 @@ export function addServices(
getTaskHistory: wrapper(getTaskHistory, controller),
askResponse: wrapper(askResponse, controller),
taskFeedback: wrapper(taskFeedback, controller),
taskCompletionViewChanges: wrapper(taskCompletionViewChanges, controller),
})
// Web Service
+224
View File
@@ -0,0 +1,224 @@
---
title: "UI Navigation Refactoring Guide for Cline"
description: "A comprehensive guide for refactoring Cline's UI navigation system from extension message-based navigation to React state management"
---
This guide provides context for refactoring Cline's UI navigation system, moving from extension message-based navigation to React state management using context and custom hooks.
## Key Files and Structure
### Message Types
Message types define the communication between the extension and webview.
- `src/shared/WebviewMessage.ts` - TypeScript interface defining the messages sent from webview to extension
- Contains message types that should be removed in favor of direct navigation functions
- Example: `"showChatView"` should be replaced with `navigateToChat()`
- `src/shared/ExtensionMessage.ts` - TypeScript interface defining the messages sent from extension to extension
- Contains navigation actions that will be handled by ExtensionStateContext
- Example: `action: "mcpButtonClicked"` triggers MCP view navigation
### Controller and Context
- `src/core/controller/index.ts` - Main controller that handles messages from the webview
- Processes incoming WebviewMessages
- Sends navigation commands via ExtensionMessages
- Contains handlers that will be removed after refactoring
- `webview-ui/src/context/ExtensionStateContext.tsx` - React context for managing UI state
- Maintains navigation state (which views are visible)
- Provides navigation functions through the context (navigateToMcp, navigateToSettings, etc.)
- Handles incoming extension messages for navigation
### Components
- `webview-ui/src/components/cline-rules/ClineRulesToggleModal.tsx` - Example component using direct message posting
- Currently uses `vscode.postMessage()` directly for some actions
- Should be updated to use navigation functions from ExtensionStateContext directly
- `webview-ui/src/components/chat/ServersToggleModal.tsx` - Example of refactored component
- Uses `navigateToMcp()` directly from ExtensionStateContext
- No longer depends on separate navigation hooks
## Understanding the Navigation Patterns
### Pattern 1: Webview-to-Extension-to-Webview (To Be Refactored)
This pattern occurs when a component wants to navigate to a different view:
1. Component sends a message to the extension: `vscode.postMessage({ type: "showChatView" })`
2. Extension controller processes it: `case "showChatView"`
3. Controller sends a navigation action back: `this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })`
4. ExtensionStateContext handles the action and updates state: `case "chatButtonClicked": navigateToChat()`
**Example from the code:**
```typescript
// WebviewMessage.ts - Message type to be removed
| "showChatView"
// controller/index.ts - Handler to be removed
case "showChatView": {
this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
break
}
// ExtensionStateContext.tsx - Handling that will remain
case "chatButtonClicked":
navigateToChat()
break
```
### Pattern 2: Extension-Initiated Navigation (Must Remain)
This pattern occurs when the extension needs to control navigation:
1. Extension sends a message: `this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" })`
2. ExtensionStateContext handles it and updates state: `case "settingsButtonClicked": navigateToSettings()`
**Example from the code:**
```typescript
// ExtensionMessage.ts
action?:
| "chatButtonClicked"
| "mcpButtonClicked"
| "settingsButtonClicked"
| "historyButtonClicked"
| "didBecomeVisible"
| "accountLogoutClicked"
| "accountButtonClicked"
| "focusChatInput"
// ExtensionStateContext.tsx
case "settingsButtonClicked":
navigateToSettings()
break
```
## The Refactoring Process
For each UI action that follows Pattern 1, follow these steps:
1. **Identify UI state variables needed**
- ExtensionStateContext already includes view state variables:
```typescript
const [showMcp, setShowMcp] = useState(false)
const [mcpTab, setMcpTab] = useState<McpViewTab | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showAccount, setShowAccount] = useState(false)
```
2. **Use navigation functions from ExtensionStateContext directly**
- ExtensionStateContext provides navigation functions:
```typescript
const navigateToMcp = useCallback(/*...*/)
const navigateToSettings = useCallback(/*...*/)
const navigateToHistory = useCallback(/*...*/)
const navigateToAccount = useCallback(/*...*/)
const navigateToChat = useCallback(/*...*/)
```
- Use these functions directly without intermediate hooks
3. **Update components to use the context**
- Replace `vscode.postMessage()` calls with context functions:
```typescript
// Before:
vscode.postMessage({ type: "showChatView" })
// After:
const { navigateToChat } = useExtensionState()
navigateToChat()
```
4. **Clean up message interfaces and controller**
- Remove the action type from WebviewMessage interface
- Remove the case handler from controller/index.ts
## Messages to Remove vs. Keep
### Messages to Remove (Pattern 1)
These messages from WebviewMessage.ts should be refactored to use direct navigation functions:
```typescript
| "showChatView" // Use navigateToChat() instead
| "openMcpSettings" // Use navigateToMcp() instead
| "openSettings" // Use navigateToSettings() instead
```
### Messages to Keep (Pattern 2)
These actions in ExtensionMessage.ts must remain as they're initiated by the extension:
```typescript
action?:
| "chatButtonClicked" // Extension initiates chat view
| "mcpButtonClicked" // Extension initiates MCP view
| "settingsButtonClicked" // Extension initiates settings view
| "historyButtonClicked" // Extension initiates history view
| "accountButtonClicked" // Extension initiates account view
```
## Example Refactoring
Let's look at how `showChatView` would be refactored:
### Before Refactoring
```typescript
// Component.tsx
const handleButtonClick = () => {
vscode.postMessage({ type: "showChatView" })
}
// controller/index.ts
case "showChatView": {
this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
break
}
// ExtensionStateContext.tsx
case "chatButtonClicked":
navigateToChat()
break
```
### After Refactoring
```typescript
// Component.tsx
const { navigateToChat } = useExtensionState()
const handleButtonClick = () => {
navigateToChat()
}
// controller/index.ts
// The showChatView case is removed
// ExtensionStateContext.tsx
// The chatButtonClicked case remains for extension-initiated navigation
case "chatButtonClicked":
navigateToChat()
break
```
## Best Practices
- **Direct Navigation**: Always use the navigation functions from ExtensionStateContext directly
- **Clean Views**: When navigating to a view, ensure other views are properly hidden
- **Consistent Naming**: Use consistent naming for navigation functions (`navigateTo<View>`)
- **Keep Extension Actions**: Maintain extension-initiated navigation actions in ExtensionMessage.ts
- **Document Dependencies**: Use proper dependency arrays in useCallback for navigation functions
- **Centralized Logic**: Keep all navigation logic in ExtensionStateContext
- **Avoid Extra Hooks**: Don't create separate hooks that merely wrap navigation functions
+23 -75
View File
@@ -1,6 +1,4 @@
import { useCallback, useEffect, useState } from "react"
import { useEvent } from "react-use"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { useEffect } from "react"
import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import SettingsView from "./components/settings/SettingsView"
@@ -12,67 +10,24 @@ import McpView from "./components/mcp/configuration/McpConfigurationView"
import { Providers } from "./Providers"
const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement, showMcp, mcpTab } = useExtensionState()
const [showSettings, setShowSettings] = useState(false)
const hideSettings = useCallback(() => setShowSettings(false), [])
const [showHistory, setShowHistory] = useState(false)
const [showAccount, setShowAccount] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false)
const { setShowMcp, setMcpTab } = useExtensionState()
const closeMcpView = useCallback(() => {
setShowMcp(false)
setMcpTab(undefined)
}, [setShowMcp, setMcpTab])
const handleMessage = useCallback(
(e: MessageEvent) => {
const message: ExtensionMessage = e.data
switch (message.type) {
case "action":
switch (message.action!) {
case "settingsButtonClicked":
setShowSettings(true)
setShowHistory(false)
closeMcpView()
setShowAccount(false)
break
case "historyButtonClicked":
setShowSettings(false)
setShowHistory(true)
closeMcpView()
setShowAccount(false)
break
case "mcpButtonClicked":
setShowSettings(false)
setShowHistory(false)
if (message.tab) {
setMcpTab(message.tab)
}
setShowMcp(true)
setShowAccount(false)
break
case "accountButtonClicked":
setShowSettings(false)
setShowHistory(false)
closeMcpView()
setShowAccount(true)
break
case "chatButtonClicked":
setShowSettings(false)
setShowHistory(false)
closeMcpView()
setShowAccount(false)
break
}
break
}
},
[setShowMcp, setMcpTab, closeMcpView],
)
useEvent("message", handleMessage)
const {
didHydrateState,
showWelcome,
shouldShowAnnouncement,
showMcp,
mcpTab,
showSettings,
showHistory,
showAccount,
showAnnouncement,
setShowAnnouncement,
closeMcpView,
navigateToHistory,
hideSettings,
hideHistory,
hideAccount,
hideAnnouncement,
} = useExtensionState()
useEffect(() => {
if (shouldShowAnnouncement) {
@@ -92,22 +47,15 @@ const AppContent = () => {
) : (
<>
{showSettings && <SettingsView onDone={hideSettings} />}
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
{showHistory && <HistoryView onDone={hideHistory} />}
{showMcp && <McpView initialTab={mcpTab} onDone={closeMcpView} />}
{showAccount && <AccountView onDone={() => setShowAccount(false)} />}
{showAccount && <AccountView onDone={hideAccount} />}
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView
showHistoryView={() => {
setShowSettings(false)
closeMcpView()
setShowAccount(false)
setShowHistory(true)
}}
showHistoryView={navigateToHistory}
isHidden={showSettings || showHistory || showMcp || showAccount}
showAnnouncement={showAnnouncement}
hideAnnouncement={() => {
setShowAnnouncement(false)
}}
hideAnnouncement={hideAnnouncement}
/>
</>
)}
@@ -36,7 +36,7 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
return (
<div style={containerStyle}>
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
<VSCodeButton data-testid="close-button" appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h3 style={h3TitleStyle}>
+9 -9
View File
@@ -18,7 +18,7 @@ import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCo
import { useExtensionState } from "@/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
import { vscode } from "@/utils/vscode"
import { FileServiceClient } from "@/services/grpc-client"
import { FileServiceClient, TaskServiceClient } from "@/services/grpc-client"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
interface CopyButtonProps {
@@ -1226,10 +1226,9 @@ export const ChatRowContent = ({
disabled={seeNewChangesDisabled}
onClick={() => {
setSeeNewChangesDisabled(true)
vscode.postMessage({
type: "taskCompletionViewChanges",
number: message.ts,
})
TaskServiceClient.taskCompletionViewChanges({
value: message.ts,
}).catch((err) => console.error("Failed to show task completion view changes:", err))
}}
style={{
cursor: seeNewChangesDisabled ? "wait" : "pointer",
@@ -1390,10 +1389,11 @@ export const ChatRowContent = ({
disabled={seeNewChangesDisabled}
onClick={() => {
setSeeNewChangesDisabled(true)
vscode.postMessage({
type: "taskCompletionViewChanges",
number: message.ts,
})
TaskServiceClient.taskCompletionViewChanges({
value: message.ts,
}).catch((err) =>
console.error("Failed to show task completion view changes:", err),
)
}}>
<i
className="codicon codicon-new-file"
+8 -2
View File
@@ -199,8 +199,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
if (textToCopy !== null) {
vscode.postMessage({ type: "copyToClipboard", text: textToCopy })
e.preventDefault()
try {
FileServiceClient.copyToClipboard({ value: textToCopy }).catch((err) => {
console.error("Error copying to clipboard:", err)
})
e.preventDefault()
} catch (error) {
console.error("Error copying to clipboard:", error)
}
}
}
}
@@ -1,7 +1,6 @@
import React, { useRef, useState, useEffect } from "react"
import { useClickAway, useWindowSize } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useNavigator } from "@/hooks/useNavigator"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import ServersToggleList from "@/components/mcp/configuration/tabs/installed/ServersToggleList"
import { vscode } from "@/utils/vscode"
@@ -9,8 +8,7 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import Tooltip from "@/components/common/Tooltip"
const ServersToggleModal: React.FC = () => {
const { mcpServers } = useExtensionState()
const { navigateToMcp } = useNavigator()
const { mcpServers, navigateToMcp } = useExtensionState()
const [isVisible, setIsVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
@@ -20,13 +20,7 @@ describe("Announcement", () => {
it("calls hideAnnouncement when close button is clicked", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
fireEvent.click(screen.getByRole("button"))
fireEvent.click(screen.getByTestId("close-button"))
expect(hideAnnouncement).toHaveBeenCalled()
})
it("renders the enhanced MCP support announcement", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
// Updated text based on actual component output
expect(screen.getByText(/Enhanced MCP Support:/)).toBeInTheDocument()
})
})
@@ -19,6 +19,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
import { McpServiceClient } from "@/services/grpc-client"
import { convertProtoMcpServersToMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { McpServers, UpdateMcpTimeoutRequest } from "@shared/proto/mcp"
import { StringRequest } from "@shared/proto/common"
// constant JSX.Elements
const TimeoutOptions = [
{ value: "30", label: "30 seconds" },
@@ -46,6 +47,7 @@ const ServerRow = ({
const [isExpanded, setIsExpanded] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isRestarting, setIsRestarting] = useState(false)
const getStatusColor = useCallback((status: McpServer["status"]) => {
switch (status) {
@@ -93,10 +95,24 @@ const ServerRow = ({
}
const handleRestart = () => {
vscode.postMessage({
type: "restartMcpServer",
text: server.name,
})
// Set local state to show "connecting" status
setIsRestarting(true)
// Make the gRPC call
McpServiceClient.restartMcpServer({
value: server.name,
} as StringRequest)
.then((response: McpServers) => {
// Update with the final state from the server
const mcpServers = convertProtoMcpServersToMcpServers(response.mcpServers)
setMcpServers(mcpServers)
setIsRestarting(false)
})
.catch((error) => {
// Reset the restarting state
setIsRestarting(false)
console.error("Error restarting MCP server", error)
})
}
const handleDelete = () => {
@@ -171,7 +187,7 @@ const ServerRow = ({
e.stopPropagation()
handleRestart()
}}
disabled={server.status === "connecting"}>
disabled={server.status === "connecting" || isRestarting}>
<span className="codicon codicon-sync"></span>
</VSCodeButton>
{hasTrashIcon && (
@@ -267,7 +283,7 @@ const ServerRow = ({
width: "calc(100% - 20px)",
margin: "0 10px 10px 10px",
}}>
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
{server.status === "connecting" || isRestarting ? "Retrying..." : "Retry Connection"}
</VSCodeButton>
<DangerButton
@@ -363,12 +379,12 @@ const ServerRow = ({
<VSCodeButton
appearance="secondary"
onClick={handleRestart}
disabled={server.status === "connecting"}
disabled={server.status === "connecting" || isRestarting}
style={{
width: "calc(100% - 14px)",
margin: "0 7px 3px 7px",
}}>
{server.status === "connecting" ? "Restarting..." : "Restart Server"}
{server.status === "connecting" || isRestarting ? "Restarting..." : "Restart Server"}
</VSCodeButton>
<DangerButton
@@ -1,7 +1,8 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import ApiOptions from "../ApiOptions"
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import { ExtensionStateContextProvider, useExtensionState } from "@/context/ExtensionStateContext"
import { ApiConfiguration } from "@shared/api"
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
@@ -16,10 +17,20 @@ vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
},
setApiConfiguration: vi.fn(),
uriScheme: "vscode",
requestyModels: {},
})),
}
})
const mockExtensionState = (apiConfiguration: Partial<ApiConfiguration>) => {
vi.mocked(useExtensionState).mockReturnValue({
apiConfiguration,
setApiConfiguration: vi.fn(),
uriScheme: "vscode",
requestyModels: {},
} as any)
}
describe("ApiOptions Component", () => {
vi.clearAllMocks()
const mockPostMessage = vi.fn()
@@ -27,6 +38,9 @@ describe("ApiOptions Component", () => {
beforeEach(() => {
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage }
mockExtensionState({
apiProvider: "requesty",
})
})
it("renders Requesty API Key input", () => {
@@ -45,28 +59,11 @@ describe("ApiOptions Component", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
const modelIdInput = screen.getByPlaceholderText("Enter Model ID...")
const modelIdInput = screen.getByPlaceholderText("Search and select a model...")
expect(modelIdInput).toBeInTheDocument()
})
})
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual || {}),
// your mocked methods
useExtensionState: vi.fn(() => ({
apiConfiguration: {
apiProvider: "together",
requestyApiKey: "",
requestyModelId: "",
},
setApiConfiguration: vi.fn(),
uriScheme: "vscode",
})),
}
})
describe("ApiOptions Component", () => {
vi.clearAllMocks()
const mockPostMessage = vi.fn()
@@ -74,6 +71,9 @@ describe("ApiOptions Component", () => {
beforeEach(() => {
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage }
mockExtensionState({
apiProvider: "together",
})
})
it("renders Together API Key input", () => {
@@ -97,24 +97,6 @@ describe("ApiOptions Component", () => {
})
})
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual || {}),
useExtensionState: vi.fn(() => ({
apiConfiguration: {
apiProvider: "fireworks",
fireworksApiKey: "",
fireworksModelId: "",
fireworksModelMaxCompletionTokens: 2000,
fireworksModelMaxTokens: 4000,
},
setApiConfiguration: vi.fn(),
uriScheme: "vscode",
})),
}
})
describe("ApiOptions Component", () => {
vi.clearAllMocks()
const mockPostMessage = vi.fn()
@@ -122,6 +104,14 @@ describe("ApiOptions Component", () => {
beforeEach(() => {
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage }
mockExtensionState({
apiProvider: "fireworks",
fireworksApiKey: "",
fireworksModelId: "",
fireworksModelMaxCompletionTokens: 2000,
fireworksModelMaxTokens: 4000,
})
})
it("renders Fireworks API Key input", () => {
@@ -165,23 +155,6 @@ describe("ApiOptions Component", () => {
})
})
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual || {}),
// your mocked methods
useExtensionState: vi.fn(() => ({
apiConfiguration: {
apiProvider: "openai",
requestyApiKey: "",
requestyModelId: "",
},
setApiConfiguration: vi.fn(),
uriScheme: "vscode",
})),
}
})
describe("OpenApiInfoOptions", () => {
const mockPostMessage = vi.fn()
@@ -189,6 +162,9 @@ describe("OpenApiInfoOptions", () => {
vi.clearAllMocks()
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage }
mockExtensionState({
apiProvider: "openai",
})
})
it("renders OpenAI Supports Images input", () => {
@@ -31,9 +31,14 @@ interface ExtensionStateContextType extends ExtensionState {
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
totalTasksSize: number | null
// View state
showMcp: boolean
mcpTab?: McpViewTab
showSettings: boolean
showHistory: boolean
showAccount: boolean
showAnnouncement: boolean
// Setters
setApiConfiguration: (config: ApiConfiguration) => void
@@ -47,9 +52,23 @@ interface ExtensionStateContextType extends ExtensionState {
setChatSettings: (value: ChatSettings) => void
setMcpServers: (value: McpServer[]) => void
// Navigation
// Navigation state setters
setShowMcp: (value: boolean) => void
setMcpTab: (tab?: McpViewTab) => void
// Navigation functions
navigateToMcp: (tab?: McpViewTab) => void
navigateToSettings: () => void
navigateToHistory: () => void
navigateToAccount: () => void
navigateToChat: () => void
// Hide functions
hideSettings: () => void
hideHistory: () => void
hideAccount: () => void
hideAnnouncement: () => void
closeMcpView: () => void
}
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -60,6 +79,64 @@ export const ExtensionStateContextProvider: React.FC<{
// UI view state
const [showMcp, setShowMcp] = useState(false)
const [mcpTab, setMcpTab] = useState<McpViewTab | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showAccount, setShowAccount] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false)
// Helper for MCP view
const closeMcpView = useCallback(() => {
setShowMcp(false)
setMcpTab(undefined)
}, [setShowMcp, setMcpTab])
// Hide functions
const hideSettings = useCallback(() => setShowSettings(false), [setShowSettings])
const hideHistory = useCallback(() => setShowHistory(false), [setShowHistory])
const hideAccount = useCallback(() => setShowAccount(false), [setShowAccount])
const hideAnnouncement = useCallback(() => setShowAnnouncement(false), [setShowAnnouncement])
// Navigation functions
const navigateToMcp = useCallback(
(tab?: McpViewTab) => {
setShowSettings(false)
setShowHistory(false)
setShowAccount(false)
if (tab) {
setMcpTab(tab)
}
setShowMcp(true)
},
[setShowMcp, setMcpTab, setShowSettings, setShowHistory, setShowAccount],
)
const navigateToSettings = useCallback(() => {
setShowHistory(false)
closeMcpView()
setShowAccount(false)
setShowSettings(true)
}, [setShowSettings, setShowHistory, closeMcpView, setShowAccount])
const navigateToHistory = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowAccount(false)
setShowHistory(true)
}, [setShowSettings, closeMcpView, setShowAccount, setShowHistory])
const navigateToAccount = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowAccount(true)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount])
const navigateToChat = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowAccount(false)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount])
const [state, setState] = useState<ExtensionState>({
version: "",
@@ -100,6 +177,26 @@ export const ExtensionStateContextProvider: React.FC<{
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "action": {
switch (message.action!) {
case "mcpButtonClicked":
navigateToMcp(message.tab)
break
case "settingsButtonClicked":
navigateToSettings()
break
case "historyButtonClicked":
navigateToHistory()
break
case "accountButtonClicked":
navigateToAccount()
break
case "chatButtonClicked":
navigateToChat()
break
}
break
}
case "state": {
// Handler for direct state messages
if (message.state) {
@@ -335,12 +432,29 @@ export const ExtensionStateContextProvider: React.FC<{
totalTasksSize,
showMcp,
mcpTab,
showSettings,
showHistory,
showAccount,
showAnnouncement,
globalClineRulesToggles: state.globalClineRulesToggles || {},
localClineRulesToggles: state.localClineRulesToggles || {},
localCursorRulesToggles: state.localCursorRulesToggles || {},
localWindsurfRulesToggles: state.localWindsurfRulesToggles || {},
workflowToggles: state.workflowToggles || {},
enableCheckpointsSetting: state.enableCheckpointsSetting,
// Navigation functions
navigateToMcp,
navigateToSettings,
navigateToHistory,
navigateToAccount,
navigateToChat,
// Hide functions
hideSettings,
hideHistory,
hideAccount,
hideAnnouncement,
setApiConfiguration: (value) =>
setState((prevState) => ({
...prevState,
@@ -383,6 +497,7 @@ export const ExtensionStateContextProvider: React.FC<{
})),
setMcpServers: (mcpServers: McpServer[]) => setMcpServers(mcpServers),
setShowMcp,
closeMcpView,
setChatSettings: (value) => {
setState((prevState) => ({
...prevState,
-24
View File
@@ -1,24 +0,0 @@
import { useExtensionState } from "../context/ExtensionStateContext"
import { McpViewTab } from "@shared/mcp"
/**
* Hook for navigating between different views in the application.
*/
export const useNavigator = () => {
const { setShowMcp, setMcpTab } = useExtensionState()
/**
* Navigate to the MCP view
* @param tab Optional tab to show in the MCP view
*/
const navigateToMcp = (tab?: McpViewTab) => {
if (tab) {
setMcpTab(tab)
}
setShowMcp(true)
}
return {
navigateToMcp,
}
}