Compare commits

...

2 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
6 changed files with 369 additions and 103 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
+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}
/>
</>
)}
@@ -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)
@@ -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,
}
}