Browse Source

Merge pull request #2 from cannuri/feature/update-main

Feature/update main
cannuri 11 months ago
parent
commit
42287ad1a0
48 changed files with 584 additions and 244 deletions
  1. 5 0
      .changeset/automatic-tags-publish.md
  2. 0 5
      .changeset/empty-bees-suffer.md
  3. 1 1
      .changeset/lemon-bulldogs-unite.md
  4. 1 2
      .env.sample
  5. 20 5
      .github/workflows/marketplace-publish.yml
  6. 5 0
      CHANGELOG.md
  7. 1 0
      esbuild.js
  8. 2 2
      package-lock.json
  9. 1 1
      package.json
  10. 18 8
      src/api/providers/__tests__/requesty.test.ts
  11. 3 3
      src/api/providers/openai.ts
  12. 26 6
      src/api/providers/requesty.ts
  13. 2 2
      src/api/providers/vscode-lm.ts
  14. 9 3
      src/core/Cline.ts
  15. 2 2
      src/core/mentions/index.ts
  16. 9 0
      src/core/webview/ClineProvider.ts
  17. 59 0
      src/core/webview/__tests__/ClineProvider.test.ts
  18. 7 3
      src/integrations/diagnostics/index.ts
  19. 1 1
      src/integrations/editor/DiffViewProvider.ts
  20. 1 1
      src/services/glob/list-files.ts
  21. 15 0
      src/services/telemetry/TelemetryService.ts
  22. 6 0
      src/services/tree-sitter/__tests__/index.test.ts
  23. 11 0
      src/services/tree-sitter/__tests__/languageParser.test.ts
  24. 3 0
      src/services/tree-sitter/index.ts
  25. 6 0
      src/services/tree-sitter/languageParser.ts
  26. 1 0
      src/services/tree-sitter/queries/index.ts
  27. 28 0
      src/services/tree-sitter/queries/kotlin.ts
  28. 103 10
      src/utils/__tests__/cost.test.ts
  29. 44 13
      src/utils/cost.ts
  30. 17 32
      webview-ui/src/App.tsx
  31. 2 2
      webview-ui/src/components/chat/Announcement.tsx
  32. 11 8
      webview-ui/src/components/chat/ChatTextArea.tsx
  33. 1 1
      webview-ui/src/components/chat/ChatView.tsx
  34. 3 11
      webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
  35. 15 0
      webview-ui/src/components/common/Alert.tsx
  36. 23 0
      webview-ui/src/components/common/Tab.tsx
  37. 9 6
      webview-ui/src/components/history/HistoryView.tsx
  38. 15 11
      webview-ui/src/components/mcp/McpView.tsx
  39. 12 6
      webview-ui/src/components/prompts/PromptsView.tsx
  40. 35 46
      webview-ui/src/components/settings/SettingsView.tsx
  41. 2 3
      webview-ui/src/components/ui/dropdown-menu.tsx
  42. 1 0
      webview-ui/src/components/ui/hooks/index.ts
  43. 10 0
      webview-ui/src/components/ui/hooks/useRooPortal.ts
  44. 2 3
      webview-ui/src/components/ui/popover.tsx
  45. 9 26
      webview-ui/src/components/ui/select-dropdown.tsx
  46. 4 2
      webview-ui/src/components/ui/select.tsx
  47. 19 19
      webview-ui/src/components/welcome/WelcomeView.tsx
  48. 4 0
      webview-ui/src/index.css

+ 5 - 0
.changeset/automatic-tags-publish.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Update GitHub Actions workflow to automatically create and push git tags during release

+ 0 - 5
.changeset/empty-bees-suffer.md

@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Revert tool progress for now

+ 1 - 1
.changeset/sixty-ants-begin.md → .changeset/lemon-bulldogs-unite.md

@@ -2,4 +2,4 @@
 "roo-cline": patch
 ---
 
-v3.8.4
+App tab layout fixes

+ 1 - 2
.env.sample

@@ -1,2 +1 @@
-# PostHog API Keys for telemetry
-POSTHOG_API_KEY=key-goes-here
+POSTHOG_API_KEY=key-goes-here

+ 20 - 5
.github/workflows/marketplace-publish.yml

@@ -10,6 +10,8 @@ env:
 jobs:
   publish-extension:
     runs-on: ubuntu-latest
+    permissions:
+      contents: write  # Required for pushing tags
     if: >
         ( github.event_name == 'pull_request' &&
         github.event.pull_request.base.ref == 'main' &&
@@ -23,24 +25,24 @@ jobs:
       - uses: actions/setup-node@v4
         with:
           node-version: 18
+
       - run: |
           git config user.name github-actions
           git config user.email [email protected]
+
       - name: Install Dependencies
         run: |
           npm install -g vsce ovsx
           npm run install:ci
+
       - name: Create .env file
         run: |
           echo "# PostHog API Keys for telemetry" > .env
           echo "POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}" >> .env
-      - name: Package and Publish Extension
-        env:
-          VSCE_PAT: ${{ secrets.VSCE_PAT }}
-          OVSX_PAT: ${{ secrets.OVSX_PAT }}
+
+      - name: Package Extension
         run: |
           current_package_version=$(node -p "require('./package.json').version")
-
           npm run vsix
           package=$(unzip -l bin/roo-cline-${current_package_version}.vsix)
           echo "$package"
@@ -49,5 +51,18 @@ jobs:
           echo "$package" | grep -q "extension/node_modules/@vscode/codicons/dist/codicon.ttf" || exit 1
           echo "$package" | grep -q ".env" || exit 1
 
+      - name: Create and Push Git Tag
+        run: |
+          current_package_version=$(node -p "require('./package.json').version")
+          git tag -a "v${current_package_version}" -m "Release v${current_package_version}"
+          git push origin "v${current_package_version}"
+          echo "Successfully created and pushed git tag v${current_package_version}"
+
+      - name: Publish Extension
+        env:
+          VSCE_PAT: ${{ secrets.VSCE_PAT }}
+          OVSX_PAT: ${{ secrets.OVSX_PAT }}
+        run: |
+          current_package_version=$(node -p "require('./package.json').version")
           npm run publish:marketplace
           echo "Successfully published version $current_package_version to VS Code Marketplace"

+ 5 - 0
CHANGELOG.md

@@ -1,5 +1,10 @@
 # Roo Code Changelog
 
+## [3.8.4] - 2025-03-09
+
+- Roll back multi-diff progress indicator temporarily to fix a double-confirmation in saving edits
+- Add an option in the prompts tab to save tokens by disabling the ability to ask Roo to create/edit custom modes for you (thanks @hannesrudolph!)
+
 ## [3.8.3] - 2025-03-09
 
 - Fix VS Code LM API model picker truncation issue

+ 1 - 0
esbuild.js

@@ -52,6 +52,7 @@ const copyWasmFiles = {
 				"java",
 				"php",
 				"swift",
+				"kotlin",
 			]
 
 			languages.forEach((lang) => {

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "roo-cline",
-	"version": "3.8.3",
+	"version": "3.8.4",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "roo-cline",
-			"version": "3.8.3",
+			"version": "3.8.4",
 			"dependencies": {
 				"@anthropic-ai/bedrock-sdk": "^0.10.2",
 				"@anthropic-ai/sdk": "^0.37.0",

+ 1 - 1
package.json

@@ -3,7 +3,7 @@
 	"displayName": "Roo Code (prev. Roo Cline)",
 	"description": "A whole dev team of AI agents in your editor.",
 	"publisher": "RooVeterinaryInc",
-	"version": "3.8.3",
+	"version": "3.8.4",
 	"icon": "assets/icons/rocket.png",
 	"galleryBanner": {
 		"color": "#617A91",

+ 18 - 8
src/api/providers/__tests__/requesty.test.ts

@@ -22,8 +22,10 @@ describe("RequestyHandler", () => {
 			contextWindow: 4000,
 			supportsPromptCache: false,
 			supportsImages: true,
-			inputPrice: 0,
-			outputPrice: 0,
+			inputPrice: 1,
+			outputPrice: 10,
+			cacheReadsPrice: 0.1,
+			cacheWritesPrice: 1.5,
 		},
 		openAiStreamingEnabled: true,
 		includeMaxTokens: true, // Add this to match the implementation
@@ -83,8 +85,12 @@ describe("RequestyHandler", () => {
 						yield {
 							choices: [{ delta: { content: " world" } }],
 							usage: {
-								prompt_tokens: 10,
-								completion_tokens: 5,
+								prompt_tokens: 30,
+								completion_tokens: 10,
+								prompt_tokens_details: {
+									cached_tokens: 15,
+									caching_tokens: 5,
+								},
 							},
 						}
 					},
@@ -105,10 +111,11 @@ describe("RequestyHandler", () => {
 					{ type: "text", text: " world" },
 					{
 						type: "usage",
-						inputTokens: 10,
-						outputTokens: 5,
-						cacheWriteTokens: undefined,
-						cacheReadTokens: undefined,
+						inputTokens: 30,
+						outputTokens: 10,
+						cacheWriteTokens: 5,
+						cacheReadTokens: 15,
+						totalCost: 0.000119, // (10 * 1 / 1,000,000) + (5 * 1.5 / 1,000,000) + (15 * 0.1 / 1,000,000) + (10 * 10 / 1,000,000)
 					},
 				])
 
@@ -182,6 +189,9 @@ describe("RequestyHandler", () => {
 						type: "usage",
 						inputTokens: 10,
 						outputTokens: 5,
+						cacheWriteTokens: 0,
+						cacheReadTokens: 0,
+						totalCost: 0.00006, // (10 * 1 / 1,000,000) + (5 * 10 / 1,000,000)
 					},
 				])
 

+ 3 - 3
src/api/providers/openai.ts

@@ -116,7 +116,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 					}
 				}
 				if (chunk.usage) {
-					yield this.processUsageMetrics(chunk.usage)
+					yield this.processUsageMetrics(chunk.usage, modelInfo)
 				}
 			}
 		} else {
@@ -139,11 +139,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 				type: "text",
 				text: response.choices[0]?.message.content || "",
 			}
-			yield this.processUsageMetrics(response.usage)
+			yield this.processUsageMetrics(response.usage, modelInfo)
 		}
 	}
 
-	protected processUsageMetrics(usage: any): ApiStreamUsageChunk {
+	protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk {
 		return {
 			type: "usage",
 			inputTokens: usage?.prompt_tokens || 0,

+ 26 - 6
src/api/providers/requesty.ts

@@ -1,9 +1,20 @@
 import axios from "axios"
 
 import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api"
-import { parseApiPrice } from "../../utils/cost"
+import { calculateApiCostOpenAI, parseApiPrice } from "../../utils/cost"
 import { ApiStreamUsageChunk } from "../transform/stream"
 import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
+import OpenAI from "openai"
+
+// Requesty usage includes an extra field for Anthropic use cases.
+// Safely cast the prompt token details section to the appropriate structure.
+interface RequestyUsage extends OpenAI.CompletionUsage {
+	prompt_tokens_details?: {
+		caching_tokens?: number
+		cached_tokens?: number
+	}
+	total_cost?: number
+}
 
 export class RequestyHandler extends OpenAiHandler {
 	constructor(options: OpenAiHandlerOptions) {
@@ -27,13 +38,22 @@ export class RequestyHandler extends OpenAiHandler {
 		}
 	}
 
-	protected override processUsageMetrics(usage: any): ApiStreamUsageChunk {
+	protected override processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk {
+		const requestyUsage = usage as RequestyUsage
+		const inputTokens = requestyUsage?.prompt_tokens || 0
+		const outputTokens = requestyUsage?.completion_tokens || 0
+		const cacheWriteTokens = requestyUsage?.prompt_tokens_details?.caching_tokens || 0
+		const cacheReadTokens = requestyUsage?.prompt_tokens_details?.cached_tokens || 0
+		const totalCost = modelInfo
+			? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
+			: 0
 		return {
 			type: "usage",
-			inputTokens: usage?.prompt_tokens || 0,
-			outputTokens: usage?.completion_tokens || 0,
-			cacheWriteTokens: usage?.cache_creation_input_tokens,
-			cacheReadTokens: usage?.cache_read_input_tokens,
+			inputTokens: inputTokens,
+			outputTokens: outputTokens,
+			cacheWriteTokens: cacheWriteTokens,
+			cacheReadTokens: cacheReadTokens,
+			totalCost: totalCost,
 		}
 	}
 }

+ 2 - 2
src/api/providers/vscode-lm.ts

@@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
 import * as vscode from "vscode"
 
 import { SingleCompletionHandler } from "../"
-import { calculateApiCost } from "../../utils/cost"
+import { calculateApiCostAnthropic } from "../../utils/cost"
 import { ApiStream } from "../transform/stream"
 import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
 import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
@@ -462,7 +462,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
 				type: "usage",
 				inputTokens: totalInputTokens,
 				outputTokens: totalOutputTokens,
-				totalCost: calculateApiCost(this.getModel().info, totalInputTokens, totalOutputTokens),
+				totalCost: calculateApiCostAnthropic(this.getModel().info, totalInputTokens, totalOutputTokens),
 			}
 		} catch (error: unknown) {
 			this.ensureCleanState()

+ 9 - 3
src/core/Cline.ts

@@ -55,7 +55,7 @@ import { ClineAskResponse } from "../shared/WebviewMessage"
 import { GlobalFileNames } from "../shared/globalFileNames"
 import { defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
 import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
-import { calculateApiCost } from "../utils/cost"
+import { calculateApiCostAnthropic } from "../utils/cost"
 import { fileExistsAtPath } from "../utils/fs"
 import { arePathsEqual, getReadablePath } from "../utils/path"
 import { parseMentions } from "./mentions"
@@ -875,7 +875,7 @@ export class Cline {
 			//  The way this agentic loop works is that cline will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
 			// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Cline is prompted to finish the task as efficiently as he can.
 
-			//const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
+			//const totalCost = this.calculateApiCostAnthropic(totalInputTokens, totalOutputTokens)
 			if (didEndLoop) {
 				// For now a task never 'completes'. This will only happen if the user hits max requests and denies resetting the count.
 				//this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
@@ -3173,7 +3173,7 @@ export class Cline {
 					cacheReads: cacheReadTokens,
 					cost:
 						totalCost ??
-						calculateApiCost(
+						calculateApiCostAnthropic(
 							this.api.getModel().info,
 							inputTokens,
 							outputTokens,
@@ -3798,6 +3798,8 @@ export class Cline {
 			return
 		}
 
+		telemetryService.captureCheckpointDiffed(this.taskId)
+
 		if (!previousCommitHash && mode === "checkpoint") {
 			const previousCheckpoint = this.clineMessages
 				.filter(({ say }) => say === "checkpoint_saved")
@@ -3849,6 +3851,8 @@ export class Cline {
 			return
 		}
 
+		telemetryService.captureCheckpointCreated(this.taskId)
+
 		// Start the checkpoint process in the background.
 		service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`).catch((err) => {
 			console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
@@ -3880,6 +3884,8 @@ export class Cline {
 		try {
 			await service.restoreCheckpoint(commitHash)
 
+			telemetryService.captureCheckpointRestored(this.taskId)
+
 			await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
 
 			if (mode === "restore") {

+ 2 - 2
src/core/mentions/index.ts

@@ -198,9 +198,9 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
 	}
 }
 
-function getWorkspaceProblems(cwd: string): string {
+async function getWorkspaceProblems(cwd: string): Promise<string> {
 	const diagnostics = vscode.languages.getDiagnostics()
-	const result = diagnosticsToProblemsString(
+	const result = await diagnosticsToProblemsString(
 		diagnostics,
 		[vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning],
 		cwd,

+ 9 - 0
src/core/webview/ClineProvider.ts

@@ -2567,6 +2567,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			properties.apiProvider = apiConfiguration.apiProvider
 		}
 
+		// Add model ID if available
+		const currentCline = this.getCurrentCline()
+		if (currentCline?.api) {
+			const { id: modelId } = currentCline.api.getModel()
+			if (modelId) {
+				properties.modelId = modelId
+			}
+		}
+
 		return properties
 	}
 }

+ 59 - 0
src/core/webview/__tests__/ClineProvider.test.ts

@@ -1652,3 +1652,62 @@ describe("ContextProxy integration", () => {
 		expect(mockContextProxy.setValues).toBeDefined()
 	})
 })
+
+describe("getTelemetryProperties", () => {
+	let provider: ClineProvider
+	let mockContext: vscode.ExtensionContext
+	let mockOutputChannel: vscode.OutputChannel
+	let mockCline: any
+
+	beforeEach(() => {
+		// Reset mocks
+		jest.clearAllMocks()
+
+		// Setup basic mocks
+		mockContext = {
+			globalState: {
+				get: jest.fn().mockImplementation((key: string) => {
+					if (key === "mode") return "code"
+					if (key === "apiProvider") return "anthropic"
+					return undefined
+				}),
+				update: jest.fn(),
+				keys: jest.fn().mockReturnValue([]),
+			},
+			secrets: { get: jest.fn(), store: jest.fn(), delete: jest.fn() },
+			extensionUri: {} as vscode.Uri,
+			globalStorageUri: { fsPath: "/test/path" },
+			extension: { packageJSON: { version: "1.0.0" } },
+		} as unknown as vscode.ExtensionContext
+
+		mockOutputChannel = { appendLine: jest.fn() } as unknown as vscode.OutputChannel
+		provider = new ClineProvider(mockContext, mockOutputChannel)
+
+		// Setup Cline instance with mocked getModel method
+		const { Cline } = require("../../Cline")
+		mockCline = new Cline()
+		mockCline.api = {
+			getModel: jest.fn().mockReturnValue({
+				id: "claude-3-7-sonnet-20250219",
+				info: { contextWindow: 200000 },
+			}),
+		}
+	})
+
+	test("includes basic properties in telemetry", async () => {
+		const properties = await provider.getTelemetryProperties()
+
+		expect(properties).toHaveProperty("vscodeVersion")
+		expect(properties).toHaveProperty("platform")
+		expect(properties).toHaveProperty("appVersion", "1.0.0")
+	})
+
+	test("includes model ID from current Cline instance if available", async () => {
+		// Add mock Cline to stack
+		await provider.addClineToStack(mockCline)
+
+		const properties = await provider.getTelemetryProperties()
+
+		expect(properties).toHaveProperty("modelId", "claude-3-7-sonnet-20250219")
+	})
+})

+ 7 - 3
src/integrations/diagnostics/index.ts

@@ -70,11 +70,12 @@ export function getNewDiagnostics(
 // // - New error in file3 (1:1)
 
 // will return empty string if no problems with the given severity are found
-export function diagnosticsToProblemsString(
+export async function diagnosticsToProblemsString(
 	diagnostics: [vscode.Uri, vscode.Diagnostic[]][],
 	severities: vscode.DiagnosticSeverity[],
 	cwd: string,
-): string {
+): Promise<string> {
+	const documents = new Map<vscode.Uri, vscode.TextDocument>()
 	let result = ""
 	for (const [uri, fileDiagnostics] of diagnostics) {
 		const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
@@ -100,7 +101,10 @@ export function diagnosticsToProblemsString(
 				}
 				const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
 				const source = diagnostic.source ? `${diagnostic.source} ` : ""
-				result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
+				const document = documents.get(uri) || (await vscode.workspace.openTextDocument(uri))
+				documents.set(uri, document)
+				const lineContent = document.lineAt(diagnostic.range.start.line).text
+				result += `\n- [${source}${label}] ${line} | ${lineContent} : ${diagnostic.message}`
 			}
 		}
 	}

+ 1 - 1
src/integrations/editor/DiffViewProvider.ts

@@ -177,7 +177,7 @@ export class DiffViewProvider {
 		initial fix is usually correct and it may just take time for linters to catch up.
 		*/
 		const postDiagnostics = vscode.languages.getDiagnostics()
-		const newProblems = diagnosticsToProblemsString(
+		const newProblems = await diagnosticsToProblemsString(
 			getNewDiagnostics(this.preDiagnostics, postDiagnostics),
 			[
 				vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)

+ 1 - 1
src/services/glob/list-files.ts

@@ -34,7 +34,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
 		"pkg",
 		"Pods",
 		".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories.
-	].map((dir) => `**/${dir}/**`)
+	].map((dir) => `${dirPath}/**/${dir}/**`)
 
 	const options = {
 		cwd: dirPath,

+ 15 - 0
src/services/telemetry/TelemetryService.ts

@@ -22,6 +22,9 @@ class PostHogClient {
 			CONVERSATION_MESSAGE: "Conversation Message",
 			MODE_SWITCH: "Mode Switched",
 			TOOL_USED: "Tool Used",
+			CHECKPOINT_CREATED: "Checkpoint Created",
+			CHECKPOINT_RESTORED: "Checkpoint Restored",
+			CHECKPOINT_DIFFED: "Checkpoint Diffed",
 		},
 	}
 
@@ -246,6 +249,18 @@ class TelemetryService {
 		})
 	}
 
+	public captureCheckpointCreated(taskId: string): void {
+		this.captureEvent(PostHogClient.EVENTS.TASK.CHECKPOINT_CREATED, { taskId })
+	}
+
+	public captureCheckpointDiffed(taskId: string): void {
+		this.captureEvent(PostHogClient.EVENTS.TASK.CHECKPOINT_DIFFED, { taskId })
+	}
+
+	public captureCheckpointRestored(taskId: string): void {
+		this.captureEvent(PostHogClient.EVENTS.TASK.CHECKPOINT_RESTORED, { taskId })
+	}
+
 	/**
 	 * Checks if telemetry is currently enabled
 	 * @returns Whether telemetry is enabled

+ 6 - 0
src/services/tree-sitter/__tests__/index.test.ts

@@ -169,6 +169,8 @@ describe("Tree-sitter Service", () => {
 				"/test/path/main.rs",
 				"/test/path/program.cpp",
 				"/test/path/code.go",
+				"/test/path/app.kt",
+				"/test/path/script.kts",
 			]
 
 			;(listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()])
@@ -197,6 +199,8 @@ describe("Tree-sitter Service", () => {
 				rs: { parser: mockParser, query: mockQuery },
 				cpp: { parser: mockParser, query: mockQuery },
 				go: { parser: mockParser, query: mockQuery },
+				kt: { parser: mockParser, query: mockQuery },
+				kts: { parser: mockParser, query: mockQuery },
 			})
 			;(fs.readFile as jest.Mock).mockResolvedValue("function test() {}")
 
@@ -207,6 +211,8 @@ describe("Tree-sitter Service", () => {
 			expect(result).toContain("main.rs")
 			expect(result).toContain("program.cpp")
 			expect(result).toContain("code.go")
+			expect(result).toContain("app.kt")
+			expect(result).toContain("script.kts")
 		})
 
 		it("should normalize paths in output", async () => {

+ 11 - 0
src/services/tree-sitter/__tests__/languageParser.test.ts

@@ -92,6 +92,17 @@ describe("Language Parser", () => {
 			expect(parsers.hpp).toBeDefined()
 		})
 
+		it("should handle Kotlin files correctly", async () => {
+			const files = ["test.kt", "test.kts"]
+			const parsers = await loadRequiredLanguageParsers(files)
+
+			expect(ParserMock.Language.load).toHaveBeenCalledWith(expect.stringContaining("tree-sitter-kotlin.wasm"))
+			expect(parsers.kt).toBeDefined()
+			expect(parsers.kts).toBeDefined()
+			expect(parsers.kt.query).toBeDefined()
+			expect(parsers.kts.query).toBeDefined()
+		})
+
 		it("should throw error for unsupported file extensions", async () => {
 			const files = ["test.unsupported"]
 

+ 3 - 0
src/services/tree-sitter/index.ts

@@ -80,6 +80,9 @@ function separateFiles(allFiles: string[]): { filesToParse: string[]; remainingF
 		"java",
 		"php",
 		"swift",
+		// Kotlin
+		"kt",
+		"kts",
 	].map((e) => `.${e}`)
 	const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
 	const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))

+ 6 - 0
src/services/tree-sitter/languageParser.ts

@@ -13,6 +13,7 @@ import {
 	javaQuery,
 	phpQuery,
 	swiftQuery,
+	kotlinQuery,
 } from "./queries"
 
 export interface LanguageParser {
@@ -120,6 +121,11 @@ export async function loadRequiredLanguageParsers(filesToParse: string[]): Promi
 				language = await loadLanguage("swift")
 				query = language.query(swiftQuery)
 				break
+			case "kt":
+			case "kts":
+				language = await loadLanguage("kotlin")
+				query = language.query(kotlinQuery)
+				break
 			default:
 				throw new Error(`Unsupported language: ${ext}`)
 		}

+ 1 - 0
src/services/tree-sitter/queries/index.ts

@@ -10,3 +10,4 @@ export { default as cQuery } from "./c"
 export { default as csharpQuery } from "./c-sharp"
 export { default as goQuery } from "./go"
 export { default as swiftQuery } from "./swift"
+export { default as kotlinQuery } from "./kotlin"

+ 28 - 0
src/services/tree-sitter/queries/kotlin.ts

@@ -0,0 +1,28 @@
+/*
+- class declarations (including interfaces)
+- function declarations
+- object declarations
+- property declarations
+- type alias declarations
+*/
+export default `
+(class_declaration
+  (type_identifier) @name.definition.class
+) @definition.class
+
+(function_declaration
+  (simple_identifier) @name.definition.function
+) @definition.function
+
+(object_declaration
+  (type_identifier) @name.definition.object
+) @definition.object
+
+(property_declaration
+  (simple_identifier) @name.definition.property
+) @definition.property
+
+(type_alias
+  (type_identifier) @name.definition.type
+) @definition.type
+`

+ 103 - 10
src/utils/__tests__/cost.test.ts

@@ -1,8 +1,8 @@
-import { calculateApiCost } from "../cost"
+import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../cost"
 import { ModelInfo } from "../../shared/api"
 
 describe("Cost Utility", () => {
-	describe("calculateApiCost", () => {
+	describe("calculateApiCostAnthropic", () => {
 		const mockModelInfo: ModelInfo = {
 			maxTokens: 8192,
 			contextWindow: 200_000,
@@ -14,7 +14,7 @@ describe("Cost Utility", () => {
 		}
 
 		it("should calculate basic input/output costs correctly", () => {
-			const cost = calculateApiCost(mockModelInfo, 1000, 500)
+			const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500)
 
 			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
 			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@@ -23,7 +23,7 @@ describe("Cost Utility", () => {
 		})
 
 		it("should handle cache writes cost", () => {
-			const cost = calculateApiCost(mockModelInfo, 1000, 500, 2000)
+			const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500, 2000)
 
 			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
 			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@@ -33,7 +33,7 @@ describe("Cost Utility", () => {
 		})
 
 		it("should handle cache reads cost", () => {
-			const cost = calculateApiCost(mockModelInfo, 1000, 500, undefined, 3000)
+			const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500, undefined, 3000)
 
 			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
 			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@@ -43,7 +43,7 @@ describe("Cost Utility", () => {
 		})
 
 		it("should handle all cost components together", () => {
-			const cost = calculateApiCost(mockModelInfo, 1000, 500, 2000, 3000)
+			const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500, 2000, 3000)
 
 			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
 			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@@ -60,17 +60,17 @@ describe("Cost Utility", () => {
 				supportsPromptCache: true,
 			}
 
-			const cost = calculateApiCost(modelWithoutPrices, 1000, 500, 2000, 3000)
+			const cost = calculateApiCostAnthropic(modelWithoutPrices, 1000, 500, 2000, 3000)
 			expect(cost).toBe(0)
 		})
 
 		it("should handle zero tokens", () => {
-			const cost = calculateApiCost(mockModelInfo, 0, 0, 0, 0)
+			const cost = calculateApiCostAnthropic(mockModelInfo, 0, 0, 0, 0)
 			expect(cost).toBe(0)
 		})
 
 		it("should handle undefined cache values", () => {
-			const cost = calculateApiCost(mockModelInfo, 1000, 500)
+			const cost = calculateApiCostAnthropic(mockModelInfo, 1000, 500)
 
 			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
 			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
@@ -85,7 +85,7 @@ describe("Cost Utility", () => {
 				cacheReadsPrice: undefined,
 			}
 
-			const cost = calculateApiCost(modelWithoutCachePrices, 1000, 500, 2000, 3000)
+			const cost = calculateApiCostAnthropic(modelWithoutCachePrices, 1000, 500, 2000, 3000)
 
 			// Should only include input and output costs
 			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
@@ -94,4 +94,97 @@ describe("Cost Utility", () => {
 			expect(cost).toBe(0.0105)
 		})
 	})
+
+	describe("calculateApiCostOpenAI", () => {
+		const mockModelInfo: ModelInfo = {
+			maxTokens: 8192,
+			contextWindow: 200_000,
+			supportsPromptCache: true,
+			inputPrice: 3.0, // $3 per million tokens
+			outputPrice: 15.0, // $15 per million tokens
+			cacheWritesPrice: 3.75, // $3.75 per million tokens
+			cacheReadsPrice: 0.3, // $0.30 per million tokens
+		}
+
+		it("should calculate basic input/output costs correctly", () => {
+			const cost = calculateApiCostOpenAI(mockModelInfo, 1000, 500)
+
+			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
+			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
+			// Total: 0.003 + 0.0075 = 0.0105
+			expect(cost).toBe(0.0105)
+		})
+
+		it("should handle cache writes cost", () => {
+			const cost = calculateApiCostOpenAI(mockModelInfo, 3000, 500, 2000)
+
+			// Input cost: (3.0 / 1_000_000) * (3000 - 2000) = 0.003
+			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
+			// Cache writes: (3.75 / 1_000_000) * 2000 = 0.0075
+			// Total: 0.003 + 0.0075 + 0.0075 = 0.018
+			expect(cost).toBeCloseTo(0.018, 6)
+		})
+
+		it("should handle cache reads cost", () => {
+			const cost = calculateApiCostOpenAI(mockModelInfo, 4000, 500, undefined, 3000)
+
+			// Input cost: (3.0 / 1_000_000) * (4000 - 3000) = 0.003
+			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
+			// Cache reads: (0.3 / 1_000_000) * 3000 = 0.0009
+			// Total: 0.003 + 0.0075 + 0.0009 = 0.0114
+			expect(cost).toBe(0.0114)
+		})
+
+		it("should handle all cost components together", () => {
+			const cost = calculateApiCostOpenAI(mockModelInfo, 6000, 500, 2000, 3000)
+
+			// Input cost: (3.0 / 1_000_000) * (6000 - 2000 - 3000) = 0.003
+			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
+			// Cache writes: (3.75 / 1_000_000) * 2000 = 0.0075
+			// Cache reads: (0.3 / 1_000_000) * 3000 = 0.0009
+			// Total: 0.003 + 0.0075 + 0.0075 + 0.0009 = 0.0189
+			expect(cost).toBe(0.0189)
+		})
+
+		it("should handle missing prices gracefully", () => {
+			const modelWithoutPrices: ModelInfo = {
+				maxTokens: 8192,
+				contextWindow: 200_000,
+				supportsPromptCache: true,
+			}
+
+			const cost = calculateApiCostOpenAI(modelWithoutPrices, 1000, 500, 2000, 3000)
+			expect(cost).toBe(0)
+		})
+
+		it("should handle zero tokens", () => {
+			const cost = calculateApiCostOpenAI(mockModelInfo, 0, 0, 0, 0)
+			expect(cost).toBe(0)
+		})
+
+		it("should handle undefined cache values", () => {
+			const cost = calculateApiCostOpenAI(mockModelInfo, 1000, 500)
+
+			// Input cost: (3.0 / 1_000_000) * 1000 = 0.003
+			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
+			// Total: 0.003 + 0.0075 = 0.0105
+			expect(cost).toBe(0.0105)
+		})
+
+		it("should handle missing cache prices", () => {
+			const modelWithoutCachePrices: ModelInfo = {
+				...mockModelInfo,
+				cacheWritesPrice: undefined,
+				cacheReadsPrice: undefined,
+			}
+
+			const cost = calculateApiCostOpenAI(modelWithoutCachePrices, 6000, 500, 2000, 3000)
+
+			// Should only include input and output costs
+			// Input cost: (3.0 / 1_000_000) * (6000 - 2000 - 3000) = 0.003
+			// Output cost: (15.0 / 1_000_000) * 500 = 0.0075
+			// Total: 0.003 + 0.0075 = 0.0105
+			expect(cost).toBe(0.0105)
+		})
+	})
 })

+ 44 - 13
src/utils/cost.ts

@@ -1,26 +1,57 @@
 import { ModelInfo } from "../shared/api"
 
-export function calculateApiCost(
+function calculateApiCostInternal(
 	modelInfo: ModelInfo,
 	inputTokens: number,
 	outputTokens: number,
-	cacheCreationInputTokens?: number,
-	cacheReadInputTokens?: number,
+	cacheCreationInputTokens: number,
+	cacheReadInputTokens: number,
 ): number {
-	const modelCacheWritesPrice = modelInfo.cacheWritesPrice
-	let cacheWritesCost = 0
-	if (cacheCreationInputTokens && modelCacheWritesPrice) {
-		cacheWritesCost = (modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens
-	}
-	const modelCacheReadsPrice = modelInfo.cacheReadsPrice
-	let cacheReadsCost = 0
-	if (cacheReadInputTokens && modelCacheReadsPrice) {
-		cacheReadsCost = (modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens
-	}
+	const cacheWritesCost = ((modelInfo.cacheWritesPrice || 0) / 1_000_000) * cacheCreationInputTokens
+	const cacheReadsCost = ((modelInfo.cacheReadsPrice || 0) / 1_000_000) * cacheReadInputTokens
 	const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
 	const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
 	const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
 	return totalCost
 }
 
+// For Anthropic compliant usage, the input tokens count does NOT include the cached tokens
+export function calculateApiCostAnthropic(
+	modelInfo: ModelInfo,
+	inputTokens: number,
+	outputTokens: number,
+	cacheCreationInputTokens?: number,
+	cacheReadInputTokens?: number,
+): number {
+	const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
+	const cacheReadInputTokensNum = cacheReadInputTokens || 0
+	return calculateApiCostInternal(
+		modelInfo,
+		inputTokens,
+		outputTokens,
+		cacheCreationInputTokensNum,
+		cacheReadInputTokensNum,
+	)
+}
+
+// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens
+export function calculateApiCostOpenAI(
+	modelInfo: ModelInfo,
+	inputTokens: number,
+	outputTokens: number,
+	cacheCreationInputTokens?: number,
+	cacheReadInputTokens?: number,
+): number {
+	const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
+	const cacheReadInputTokensNum = cacheReadInputTokens || 0
+	const nonCachedInputTokens = Math.max(0, inputTokens - cacheCreationInputTokensNum - cacheReadInputTokensNum)
+	return calculateApiCostInternal(
+		modelInfo,
+		nonCachedInputTokens,
+		outputTokens,
+		cacheCreationInputTokensNum,
+		cacheReadInputTokensNum,
+	)
+}
+
 export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)

+ 17 - 32
webview-ui/src/App.tsx

@@ -17,6 +17,12 @@ import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
 
 type Tab = "settings" | "history" | "mcp" | "prompts" | "chat"
 
+type HumanRelayDialogState = {
+	isOpen: boolean
+	requestId: string
+	promptText: string
+}
+
 const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
 	chatButtonClicked: "chat",
 	settingsButtonClicked: "settings",
@@ -24,24 +30,21 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
 	mcpButtonClicked: "mcp",
 	historyButtonClicked: "history",
 }
+
 const App = () => {
 	const { didHydrateState, showWelcome, shouldShowAnnouncement, telemetrySetting, telemetryKey, machineId } =
 		useExtensionState()
+
 	const [showAnnouncement, setShowAnnouncement] = useState(false)
 	const [tab, setTab] = useState<Tab>("chat")
-	const settingsRef = useRef<SettingsViewRef>(null)
-
-	// Human Relay Dialog Status
-	const [humanRelayDialogState, setHumanRelayDialogState] = useState<{
-		isOpen: boolean
-		requestId: string
-		promptText: string
-	}>({
+	const [humanRelayDialogState, setHumanRelayDialogState] = useState<HumanRelayDialogState>({
 		isOpen: false,
 		requestId: "",
 		promptText: "",
 	})
 
+	const settingsRef = useRef<SettingsViewRef>(null)
+
 	const switchTab = useCallback((newTab: Tab) => {
 		if (settingsRef.current?.checkUnsaveChanges) {
 			settingsRef.current.checkUnsaveChanges(() => setTab(newTab))
@@ -74,23 +77,6 @@ const App = () => {
 		[switchTab],
 	)
 
-	// Processing Human Relay Dialog Submission
-	const handleHumanRelaySubmit = (requestId: string, text: string) => {
-		vscode.postMessage({
-			type: "humanRelayResponse",
-			requestId,
-			text,
-		})
-	}
-
-	// Handle Human Relay dialog box cancel
-	const handleHumanRelayCancel = (requestId: string) => {
-		vscode.postMessage({
-			type: "humanRelayCancel",
-			requestId,
-		})
-	}
-
 	useEvent("message", onMessage)
 
 	useEffect(() => {
@@ -106,7 +92,7 @@ const App = () => {
 		}
 	}, [telemetrySetting, telemetryKey, machineId, didHydrateState])
 
-	// Tell Extension that we are ready to receive messages
+	// Tell the extension that we are ready to receive messages.
 	useEffect(() => {
 		vscode.postMessage({ type: "webviewDidLaunch" })
 	}, [])
@@ -121,24 +107,23 @@ const App = () => {
 		<WelcomeView />
 	) : (
 		<>
-			{tab === "settings" && <SettingsView ref={settingsRef} onDone={() => setTab("chat")} />}
-			{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
-			{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
 			{tab === "prompts" && <PromptsView onDone={() => switchTab("chat")} />}
+			{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
+			{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
+			{tab === "settings" && <SettingsView ref={settingsRef} onDone={() => setTab("chat")} />}
 			<ChatView
 				isHidden={tab !== "chat"}
 				showAnnouncement={showAnnouncement}
 				hideAnnouncement={() => setShowAnnouncement(false)}
 				showHistoryView={() => switchTab("history")}
 			/>
-			{/* Human Relay Dialog */}
 			<HumanRelayDialog
 				isOpen={humanRelayDialogState.isOpen}
 				requestId={humanRelayDialogState.requestId}
 				promptText={humanRelayDialogState.promptText}
 				onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
-				onSubmit={handleHumanRelaySubmit}
-				onCancel={handleHumanRelayCancel}
+				onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
+				onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
 			/>
 		</>
 	)

+ 2 - 2
webview-ui/src/components/chat/Announcement.tsx

@@ -33,7 +33,7 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
 			</p>
 
 			<h3 style={{ margin: "12px 0 8px" }}>What's New</h3>
-			<p style={{ margin: "5px 0px" }}>
+			<div style={{ margin: "5px 0px" }}>
 				<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
 					<li>• Faster asynchronous checkpoints</li>
 					<li>• Support for .rooignore files</li>
@@ -44,7 +44,7 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
 					<li>• Updated DeepSeek provider</li>
 					<li>• New "Human Relay" provider</li>
 				</ul>
-			</p>
+			</div>
 
 			<p style={{ margin: "10px 0px 0px" }}>
 				Get more details and discuss in{" "}

+ 11 - 8
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -1,22 +1,25 @@
 import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
 import DynamicTextArea from "react-textarea-autosize"
+
 import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
-import { useExtensionState } from "../../context/ExtensionStateContext"
+import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
+import { Mode, getAllModes } from "../../../../src/shared/modes"
+
+import { vscode } from "@/utils/vscode"
 import {
 	ContextMenuOptionType,
 	getContextMenuOptions,
 	insertMention,
 	removeMention,
 	shouldShowContextMenu,
-} from "../../utils/context-mentions"
-import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
-import ContextMenu from "./ContextMenu"
+} from "@/utils/context-mentions"
+import { SelectDropdown, DropdownOptionType } from "@/components/ui"
+
+import { useExtensionState } from "../../context/ExtensionStateContext"
 import Thumbnails from "../common/Thumbnails"
-import { vscode } from "../../utils/vscode"
-import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
-import { Mode, getAllModes } from "../../../../src/shared/modes"
 import { convertToMentionPath } from "../../utils/path-mentions"
-import { SelectDropdown, DropdownOptionType } from "../ui"
+import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
+import ContextMenu from "./ContextMenu"
 
 interface ChatTextAreaProps {
 	inputValue: string

+ 1 - 1
webview-ui/src/components/chat/ChatView.tsx

@@ -1275,7 +1275,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				modeShortcutText={modeShortcutText}
 			/>
 
-			<div id="chat-view-portal" />
+			<div id="roo-portal" />
 		</div>
 	)
 }

+ 3 - 11
webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx

@@ -1,7 +1,8 @@
-import { useState, useEffect, useCallback } from "react"
+import { useState, useCallback } from "react"
 import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"
 
 import { Button, Popover, PopoverContent, PopoverTrigger } from "@/components/ui"
+import { useRooPortal } from "@/components/ui/hooks"
 
 import { vscode } from "../../../utils/vscode"
 import { Checkpoint } from "./schema"
@@ -14,9 +15,9 @@ type CheckpointMenuProps = {
 }
 
 export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: CheckpointMenuProps) => {
-	const [portalContainer, setPortalContainer] = useState<HTMLElement>()
 	const [isOpen, setIsOpen] = useState(false)
 	const [isConfirming, setIsConfirming] = useState(false)
+	const portalContainer = useRooPortal("roo-portal")
 
 	const isCurrent = currentHash === commitHash
 	const isFirst = checkpoint.isFirst
@@ -42,15 +43,6 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
 		setIsOpen(false)
 	}, [ts, commitHash])
 
-	useEffect(() => {
-		// The dropdown menu uses a portal from @shadcn/ui which by default renders
-		// at the document root. This causes the menu to remain visible even when
-		// the parent ChatView component is hidden (during settings/history view).
-		// By moving the portal inside ChatView, the menu will properly hide when
-		// its parent is hidden.
-		setPortalContainer(document.getElementById("chat-view-portal") || undefined)
-	}, [])
-
 	return (
 		<div className="flex flex-row gap-1">
 			{isDiffAvailable && (

+ 15 - 0
webview-ui/src/components/common/Alert.tsx

@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+import { HTMLAttributes } from "react"
+
+type AlertProps = HTMLAttributes<HTMLDivElement>
+
+export const Alert = ({ className, children, ...props }: AlertProps) => (
+	<div
+		className={cn(
+			"text-vscode-inputValidation-infoForeground bg-vscode-inputValidation-infoBackground border border-vscode-inputValidation-infoBorder rounded-xs p-2",
+			className,
+		)}
+		{...props}>
+		{children}
+	</div>
+)

+ 23 - 0
webview-ui/src/components/common/Tab.tsx

@@ -0,0 +1,23 @@
+import { HTMLAttributes } from "react"
+
+import { cn } from "@/lib/utils"
+
+type TabProps = HTMLAttributes<HTMLDivElement>
+
+export const Tab = ({ className, children, ...props }: TabProps) => (
+	<div className={cn("fixed inset-0 flex flex-col overflow-hidden", className)} {...props}>
+		{children}
+	</div>
+)
+
+export const TabHeader = ({ className, children, ...props }: TabProps) => (
+	<div className={cn("px-5 py-2.5 border-b border-vscode-panel-border", className)} {...props}>
+		{children}
+	</div>
+)
+
+export const TabContent = ({ className, children, ...props }: TabProps) => (
+	<div className={cn("flex-1 overflow-auto p-5", className)} {...props}>
+		{children}
+	</div>
+)

+ 9 - 6
webview-ui/src/components/history/HistoryView.tsx

@@ -9,6 +9,7 @@ import { formatLargeNumber, formatDate } from "@/utils/format"
 import { cn } from "@/lib/utils"
 import { Button } from "@/components/ui"
 
+import { Tab, TabContent, TabHeader } from "../common/Tab"
 import { useTaskSearch } from "./useTaskSearch"
 import { ExportButton } from "./ExportButton"
 import { CopyButton } from "./CopyButton"
@@ -25,8 +26,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
 
 	return (
-		<div className="fixed inset-0 flex flex-col">
-			<div className="flex flex-col gap-2 px-5 py-2.5 border-b border-vscode-panel-border">
+		<Tab>
+			<TabHeader className="flex flex-col gap-2">
 				<div className="flex justify-between items-center">
 					<h3 className="text-vscode-foreground m-0">History</h3>
 					<VSCodeButton onClick={onDone}>Done</VSCodeButton>
@@ -81,8 +82,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 						</VSCodeRadio>
 					</VSCodeRadioGroup>
 				</div>
-			</div>
-			<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
+			</TabHeader>
+
+			<TabContent className="p-0">
 				<Virtuoso
 					style={{
 						flexGrow: 1,
@@ -312,11 +314,12 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 						</div>
 					)}
 				/>
-			</div>
+			</TabContent>
+
 			{deleteTaskId && (
 				<DeleteTaskDialog taskId={deleteTaskId} onOpenChange={(open) => !open && setDeleteTaskId(null)} open />
 			)}
-		</div>
+		</Tab>
 	)
 }
 

+ 15 - 11
webview-ui/src/components/mcp/McpView.tsx

@@ -1,3 +1,4 @@
+import { useState } from "react"
 import {
 	VSCodeButton,
 	VSCodeCheckbox,
@@ -6,14 +7,17 @@ import {
 	VSCodePanelTab,
 	VSCodePanelView,
 } from "@vscode/webview-ui-toolkit/react"
-import { useState } from "react"
-import { vscode } from "../../utils/vscode"
-import { useExtensionState } from "../../context/ExtensionStateContext"
+
 import { McpServer } from "../../../../src/shared/mcp"
+
+import { vscode } from "@/utils/vscode"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui"
+
+import { useExtensionState } from "../../context/ExtensionStateContext"
+import { Tab, TabContent, TabHeader } from "../common/Tab"
 import McpToolRow from "./McpToolRow"
 import McpResourceRow from "./McpResourceRow"
 import McpEnabledToggle from "./McpEnabledToggle"
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"
 
 type McpViewProps = {
 	onDone: () => void
@@ -29,12 +33,13 @@ const McpView = ({ onDone }: McpViewProps) => {
 	} = useExtensionState()
 
 	return (
-		<div className="fixed inset-0 flex flex-col">
-			<div className="flex justify-between items-center px-5 py-2.5 border-b border-vscode-panel-border">
+		<Tab>
+			<TabHeader className="flex justify-between items-center">
 				<h3 className="text-vscode-foreground m-0">MCP Servers</h3>
 				<VSCodeButton onClick={onDone}>Done</VSCodeButton>
-			</div>
-			<div className="flex-1 overflow-auto p-5">
+			</TabHeader>
+
+			<TabContent>
 				<div
 					style={{
 						color: "var(--vscode-foreground)",
@@ -103,12 +108,11 @@ const McpView = ({ onDone }: McpViewProps) => {
 						</div>
 					</>
 				)}
-			</div>
-		</div>
+			</TabContent>
+		</Tab>
 	)
 }
 
-// Server Row Component
 const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowMcp?: boolean }) => {
 	const [isExpanded, setIsExpanded] = useState(false)
 	const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)

+ 12 - 6
webview-ui/src/components/prompts/PromptsView.tsx

@@ -42,6 +42,7 @@ import {
 
 import { TOOL_GROUPS, GROUP_DISPLAY_NAMES, ToolGroup } from "../../../../src/shared/tool-groups"
 import { vscode } from "../../utils/vscode"
+import { Tab, TabContent, TabHeader } from "../common/Tab"
 
 // Get all available groups that should show in prompts view
 const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
@@ -419,12 +420,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	}
 
 	return (
-		<div className="fixed inset-0 flex flex-col">
-			<div className="flex justify-between items-center px-5 py-2.5 border-b border-vscode-panel-border">
+		<Tab>
+			<TabHeader className="flex justify-between items-center">
 				<h3 className="text-vscode-foreground m-0">Prompts</h3>
 				<VSCodeButton onClick={onDone}>Done</VSCodeButton>
-			</div>
-			<div className="flex-1 overflow-auto p-5">
+			</TabHeader>
+
+			<TabContent>
 				<div className="pb-5 border-b border-vscode-input-border">
 					<div className="mb-5">
 						<div className="font-bold mb-1">Preferred Language</div>
@@ -946,6 +948,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						</div>
 					</div>
 				</div>
+
 				<div
 					style={{
 						paddingBottom: "40px",
@@ -1213,7 +1216,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						)}
 					</div>
 				</div>
-			</div>
+			</TabContent>
+
 			{isCreateModeDialogOpen && (
 				<div
 					style={{
@@ -1437,6 +1441,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					</div>
 				</div>
 			)}
+
 			{isDialogOpen && (
 				<div
 					style={{
@@ -1504,6 +1509,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					</div>
 				</div>
 			)}
+
 			{isCustomLanguage && (
 				<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
 					<div className="bg-[var(--vscode-editor-background)] p-6 rounded-lg w-96">
@@ -1538,7 +1544,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					</div>
 				</div>
 			)}
-		</div>
+		</Tab>
 	)
 }
 

+ 35 - 46
webview-ui/src/components/settings/SettingsView.tsx

@@ -22,6 +22,7 @@ import {
 	Button,
 } from "@/components/ui"
 
+import { Tab, TabContent, TabHeader } from "../common/Tab"
 import { SetCachedStateField, SetExperimentEnabled } from "./types"
 import { SectionHeader } from "./SectionHeader"
 import ApiConfigManager from "./ApiConfigManager"
@@ -263,53 +264,41 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 	const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => ref.current?.scrollIntoView()
 
 	return (
-		<div className="fixed inset-0 flex flex-col overflow-hidden">
-			<div className="px-5 py-2.5 border-b border-vscode-panel-border">
-				<div className="flex flex-col">
-					<div className="flex justify-between items-center">
-						<div className="flex items-center gap-2">
-							<h3 className="text-vscode-foreground m-0">Settings</h3>
-							<div className="hidden [@media(min-width:400px)]:flex items-center">
-								{sections.map(({ id, icon: Icon, ref }) => (
-									<Button
-										key={id}
-										variant="ghost"
-										onClick={() => scrollToSection(ref)}
-										className={cn("w-6 h-6", activeSection === id ? "opacity-100" : "opacity-40")}>
-										<Icon />
-									</Button>
-								))}
-							</div>
-						</div>
-						<div className="flex gap-2">
-							<VSCodeButton
-								appearance={isSettingValid ? "primary" : "secondary"}
-								className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
-								title={
-									!isSettingValid
-										? errorMessage
-										: isChangeDetected
-											? "Save changes"
-											: "Nothing changed"
-								}
-								onClick={handleSubmit}
-								disabled={!isChangeDetected || !isSettingValid}>
-								Save
-							</VSCodeButton>
-							<VSCodeButton
-								appearance="secondary"
-								title="Discard unsaved changes and close settings panel"
-								onClick={() => checkUnsaveChanges(onDone)}>
-								Done
-							</VSCodeButton>
-						</div>
+		<Tab>
+			<TabHeader className="flex justify-between items-center gap-2">
+				<div className="flex items-center gap-2">
+					<h3 className="text-vscode-foreground m-0">Settings</h3>
+					<div className="hidden [@media(min-width:400px)]:flex items-center">
+						{sections.map(({ id, icon: Icon, ref }) => (
+							<Button
+								key={id}
+								variant="ghost"
+								onClick={() => scrollToSection(ref)}
+								className={cn("w-6 h-6", activeSection === id ? "opacity-100" : "opacity-40")}>
+								<Icon />
+							</Button>
+						))}
 					</div>
 				</div>
-			</div>
+				<div className="flex gap-2">
+					<VSCodeButton
+						appearance={isSettingValid ? "primary" : "secondary"}
+						className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
+						title={!isSettingValid ? errorMessage : isChangeDetected ? "Save changes" : "Nothing changed"}
+						onClick={handleSubmit}
+						disabled={!isChangeDetected || !isSettingValid}>
+						Save
+					</VSCodeButton>
+					<VSCodeButton
+						appearance="secondary"
+						title="Discard unsaved changes and close settings panel"
+						onClick={() => checkUnsaveChanges(onDone)}>
+						Done
+					</VSCodeButton>
+				</div>
+			</TabHeader>
 
-			<div
-				className="flex flex-col flex-1 overflow-auto divide-y divide-vscode-sideBar-background"
-				onScroll={handleScroll}>
+			<TabContent className="p-0 divide-y divide-vscode-sideBar-background" onScroll={handleScroll}>
 				<div ref={providersRef}>
 					<SectionHeader>
 						<div className="flex items-center gap-2">
@@ -425,7 +414,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 					telemetrySetting={telemetrySetting}
 					setTelemetrySetting={setTelemetrySetting}
 				/>
-			</div>
+			</TabContent>
 
 			<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
 				<AlertDialogContent>
@@ -442,7 +431,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 					</AlertDialogFooter>
 				</AlertDialogContent>
 			</AlertDialog>
-		</div>
+		</Tab>
 	)
 })
 

+ 2 - 3
webview-ui/src/components/ui/dropdown-menu.tsx

@@ -1,5 +1,6 @@
 import * as React from "react"
 import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { PortalProps } from "@radix-ui/react-portal"
 import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
 
 import { cn } from "@/lib/utils"
@@ -53,9 +54,7 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
 
 const DropdownMenuContent = React.forwardRef<
 	React.ElementRef<typeof DropdownMenuPrimitive.Content>,
-	React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
-		container?: HTMLElement
-	}
+	React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & Pick<PortalProps, "container">
 >(({ className, sideOffset = 4, container, ...props }, ref) => (
 	<DropdownMenuPrimitive.Portal container={container}>
 		<DropdownMenuPrimitive.Content

+ 1 - 0
webview-ui/src/components/ui/hooks/index.ts

@@ -1 +1,2 @@
 export * from "./useClipboard"
+export * from "./useRooPortal"

+ 10 - 0
webview-ui/src/components/ui/hooks/useRooPortal.ts

@@ -0,0 +1,10 @@
+import { useState } from "react"
+import { useMount } from "react-use"
+
+export const useRooPortal = (id: string) => {
+	const [container, setContainer] = useState<HTMLElement>()
+
+	useMount(() => setContainer(document.getElementById(id) ?? undefined))
+
+	return container
+}

+ 2 - 3
webview-ui/src/components/ui/popover.tsx

@@ -1,4 +1,5 @@
 import * as React from "react"
+import { PortalProps } from "@radix-ui/react-portal"
 import * as PopoverPrimitive from "@radix-ui/react-popover"
 
 import { cn } from "@/lib/utils"
@@ -11,9 +12,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor
 
 const PopoverContent = React.forwardRef<
 	React.ElementRef<typeof PopoverPrimitive.Content>,
-	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
-		container?: HTMLElement
-	}
+	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & Pick<PortalProps, "container">
 >(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
 	<PopoverPrimitive.Portal container={container}>
 		<PopoverPrimitive.Content

+ 9 - 26
webview-ui/src/components/ui/select-dropdown.tsx

@@ -1,4 +1,8 @@
 import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+import { useRooPortal } from "./hooks/useRooPortal"
 import {
 	DropdownMenu,
 	DropdownMenuContent,
@@ -6,10 +10,7 @@ import {
 	DropdownMenuTrigger,
 	DropdownMenuSeparator,
 } from "./dropdown-menu"
-import { cn } from "@/lib/utils"
-import { useEffect, useState } from "react"
 
-// Constants for option types
 export enum DropdownOptionType {
 	ITEM = "item",
 	SEPARATOR = "separator",
@@ -20,7 +21,7 @@ export interface DropdownOption {
 	value: string
 	label: string
 	disabled?: boolean
-	type?: DropdownOptionType // Optional type to specify special behaviors
+	type?: DropdownOptionType
 }
 
 export interface SelectDropdownProps {
@@ -58,34 +59,19 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
 		},
 		ref,
 	) => {
-		// Track open state
 		const [open, setOpen] = React.useState(false)
-		const [portalContainer, setPortalContainer] = useState<HTMLElement>()
-
-		useEffect(() => {
-			// The dropdown menu uses a portal from @shadcn/ui which by default renders
-			// at the document root. This causes the menu to remain visible even when
-			// the parent ChatView component is hidden (during settings/history view).
-			// By moving the portal inside ChatView, the menu will properly hide when
-			// its parent is hidden.
-			setPortalContainer(document.getElementById("chat-view-portal") || undefined)
-		}, [])
+		const portalContainer = useRooPortal("roo-portal")
 
-		// Find the selected option label
 		const selectedOption = options.find((option) => option.value === value)
 		const displayText = selectedOption?.label || placeholder || ""
 
-		// Handle menu item click
 		const handleSelect = (option: DropdownOption) => {
-			// Check if this is an action option by its explicit type
 			if (option.type === DropdownOptionType.ACTION) {
-				window.postMessage({
-					type: "action",
-					action: option.value,
-				})
+				window.postMessage({ type: "action", action: option.value })
 				setOpen(false)
 				return
 			}
+
 			onChange(option.value)
 			setOpen(false)
 		}
@@ -103,7 +89,7 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
 						triggerClassName,
 					)}
 					style={{
-						width: "100%", // Take full width of parent
+						width: "100%", // Take full width of parent.
 						minWidth: "0",
 						maxWidth: "100%",
 					}}>
@@ -136,12 +122,10 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
 						contentClassName,
 					)}>
 					{options.map((option, index) => {
-						// Handle separator type
 						if (option.type === DropdownOptionType.SEPARATOR) {
 							return <DropdownMenuSeparator key={`sep-${index}`} />
 						}
 
-						// Handle shortcut text type (disabled label for keyboard shortcuts)
 						if (
 							option.type === DropdownOptionType.SHORTCUT ||
 							(option.disabled && shortcutText && option.label.includes(shortcutText))
@@ -153,7 +137,6 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
 							)
 						}
 
-						// Regular menu items
 						return (
 							<DropdownMenuItem
 								key={`item-${option.value}`}

+ 4 - 2
webview-ui/src/components/ui/select.tsx

@@ -1,4 +1,5 @@
 import * as React from "react"
+import { PortalProps } from "@radix-ui/react-portal"
 import * as SelectPrimitive from "@radix-ui/react-select"
 import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
 
@@ -37,10 +38,11 @@ function SelectContent({
 	className,
 	children,
 	position = "popper",
+	container,
 	...props
-}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+}: React.ComponentProps<typeof SelectPrimitive.Content> & Pick<PortalProps, "container">) {
 	return (
-		<SelectPrimitive.Portal>
+		<SelectPrimitive.Portal container={container}>
 			<SelectPrimitive.Content
 				data-slot="select-content"
 				className={cn(

+ 19 - 19
webview-ui/src/components/welcome/WelcomeView.tsx

@@ -1,9 +1,12 @@
-import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
 import { useCallback, useState } from "react"
+import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
+
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { validateApiConfiguration } from "../../utils/validate"
 import { vscode } from "../../utils/vscode"
 import ApiOptions from "../settings/ApiOptions"
+import { Tab, TabContent } from "../common/Tab"
+import { Alert } from "../common/Alert"
 
 const WelcomeView = () => {
 	const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme } = useExtensionState()
@@ -23,18 +26,16 @@ const WelcomeView = () => {
 	}, [apiConfiguration, currentApiConfigName])
 
 	return (
-		<div className="flex flex-col min-h-screen px-0 pb-5">
-			<h2>Hi, I'm Roo!</h2>
-			<p>
-				I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities and access
-				to tools that let me create & edit files, explore complex projects, use the browser, and execute
-				terminal commands (with your permission, of course). I can even use MCP to create new tools and extend
-				my own capabilities.
-			</p>
-
-			<b>To get started, this extension needs an API provider.</b>
-
-			<div className="mt-3">
+		<Tab>
+			<TabContent className="flex flex-col gap-5">
+				<h2 className="m-0 p-0">Hi, I'm Roo!</h2>
+				<div>
+					I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities and
+					access to tools that let me create & edit files, explore complex projects, use the browser, and
+					execute terminal commands (with your permission, of course). I can even use MCP to create new tools
+					and extend my own capabilities.
+				</div>
+				<Alert className="font-bold text-sm">To get started, this extension needs an API provider.</Alert>
 				<ApiOptions
 					fromWelcomeView
 					apiConfiguration={apiConfiguration || {}}
@@ -43,15 +44,14 @@ const WelcomeView = () => {
 					errorMessage={errorMessage}
 					setErrorMessage={setErrorMessage}
 				/>
-			</div>
-
-			<div className="sticky bottom-0 bg-[var(--vscode-sideBar-background)] py-3">
-				<div className="flex flex-col gap-1.5">
+			</TabContent>
+			<div className="sticky bottom-0 bg-vscode-sideBar-background p-5">
+				<div className="flex flex-col gap-1">
 					<VSCodeButton onClick={handleSubmit}>Let's go!</VSCodeButton>
-					{errorMessage && <span className="text-destructive">{errorMessage}</span>}
+					{errorMessage && <div className="text-vscode-errorForeground">{errorMessage}</div>}
 				</div>
 			</div>
-		</div>
+		</Tab>
 	)
 }
 

+ 4 - 0
webview-ui/src/index.css

@@ -111,6 +111,10 @@
 
 	--color-vscode-charts-green: var(--vscode-charts-green);
 	--color-vscode-charts-yellow: var(--vscode-charts-yellow);
+
+	--color-vscode-inputValidation-infoForeground: var(--vscode-inputValidation-infoForeground);
+	--color-vscode-inputValidation-infoBackground: var(--vscode-inputValidation-infoBackground);
+	--color-vscode-inputValidation-infoBorder: var(--vscode-inputValidation-infoBorder);
 }
 
 @layer base {