diff --git a/creatormodetask.md b/creatormodetask.md new file mode 100644 index 00000000000..9f24671fc6e --- /dev/null +++ b/creatormodetask.md @@ -0,0 +1,52 @@ +# Creator Mode Task Description + +## Overview + +This project is an **agentic AI extension** for VSCode, forked from **RooCode**, which itself is a fork of **Cline**. Our fork is called **PearAI-Roo-Code**. + +## Goal + +We are implementing a **new view called Creator Mode** into this extension. The objective is to introduce a **separate GUI file structure** to minimize merge conflicts when pulling upstream. However, separation is not a strict requirement—if it makes more sense to integrate changes within the existing structure, we will do so. + +## Guidelines + +- **Maintain Separation:** GUI files should be kept separate to minimize merge conflicts when pulling upstream. +- **Flexible Code Organization:** While separation is preferred, prioritize maintainability and efficiency over strict separation. +- **Keep This Document Updated:** Ensure this file always reflects the latest task status for continuity. +- **Clear and Concise Development:** No unnecessary complexity—keep solutions to the point and functional. +- no bs. +- dont hallucinate. + +## Creator Mode Details + +- Creator Mode will feature a **text input box** at the center, allowing users to enter prompts. +- The design will differ from the existing **ChatView** (`chatview.tsx`), but function similarly in terms of input handling. +- When the user enters a prompt, it will be sent to the **selected AI model**. +- The response will generate a **new file containing an action plan**, which the user can edit directly. +- A new **mode identifier** (`Creator Mode`) will be introduced, similar to the existing **Ask Mode** and **Architect Mode**. + +## Development Plan (Step-by-Step) + +[COMPLETED] - add completed tasks here + +[NEXT STEPS] + +1. **Initial Input Box Implementation** + + - Create a simple input box that sends user input to the selected model. + - Ensure basic communication between the UI and backend. + - Validate that the model receives and processes input correctly. + +2. **Introduce Creator Mode** + + - Understand how existing "ask" mode and "architect" mode are implemented. + - Add `Creator Mode` to the list of available modes. + - Ensure the UI adapts accordingly when this mode is selected. + +3. **Implement File Edit Functionality** + - Modify the response handling to generate an editable file. + - Provide a smooth user experience for modifying action plans. + +- Proceed step-by-step, ensuring each milestone is functional before progressing, so begin with step **1: Implementing the input box** and verifying AI model communication, once stable, move on to **step 2: introducing Creator Mode**. + +This file should be regularly updated to reflect the project's current state and objectives. diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 69e257e7a51..4a48fbb9b65 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -41,6 +41,38 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt "roo-cline.helpButtonClicked": () => { vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com")) }, + "roo-cline.executeCreatorPlan": async (args: any) => { + const sidebarProvider = ClineProvider.getSidebarInstance() + if (sidebarProvider) { + // Start a new chat in the sidebar + vscode.commands.executeCommand("pearai-roo-cline.SidebarProvider.focus") + await sidebarProvider.clearTask() + await sidebarProvider.handleModeSwitch("code") + await sidebarProvider.postStateToWebview() + await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + + // Create the template message using the args + let executePlanTemplate = `This file contains detailed plan to my task. please read it and Execute the plan accordingly. + File: ${args.filePath || "No file specified"}` + + if (args.code) { + executePlanTemplate += `Code: \`\`\` + ${args.code} + \`\`\` + ` + } + + if (args.context) { + executePlanTemplate += `Additional context: ${args.context}` + } + + await sidebarProvider.postMessageToWebview({ + type: "invoke", + invoke: "sendMessage", + text: executePlanTemplate, + }) + } + }, } } @@ -51,21 +83,21 @@ const openClineInNewTab = async ({ context, outputChannel }: Omit editor.viewColumn || 0)) + // const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) // Check if there are any visible text editors, otherwise open a new group // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + // const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } + // if (!hasVisibleEditors) { + // await vscode.commands.executeCommand("workbench.action.newGroupRight") + // } - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + // const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, { + const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [context.extensionUri], @@ -82,5 +114,5 @@ const openClineInNewTab = async ({ context, outputChannel }: Omit (with optional line break after) and (with optional line break before) // - Needs to be separate since we dont want to remove the line break before the first tag // - Needs to happen before the xml parsing below @@ -1184,6 +1184,7 @@ export class Cline { return `[${block.name} in ${modeName} mode: '${message}']` } } + return `[${block.name}]` } if (this.didRejectTool) { @@ -2618,26 +2619,6 @@ export class Cline { } case "attempt_completion": { - /* - this.consecutiveMistakeCount = 0 - let resultToSend = result - if (command) { - await this.say("completion_result", resultToSend) - // TODO: currently we don't handle if this command fails, it could be useful to let cline know and retry - const [didUserReject, commandResult] = await this.executeCommand(command, true) - // if we received non-empty string, the command was rejected or failed - if (commandResult) { - return [didUserReject, commandResult] - } - resultToSend = "" - } - const { response, text, images } = await this.ask("completion_result", resultToSend) // this prompts webview to show 'new task' button, and enable text input (which would be the 'text' here) - if (response === "yesButtonClicked") { - return [false, ""] // signals to recursive loop to stop (for now this never happens since yesButtonClicked will trigger a new task) - } - await this.say("user_feedback", text ?? "", images) - return [ - */ const result: string | undefined = block.params.result const command: string | undefined = block.params.command try { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4f97d3771ad..af40475129e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -48,6 +48,7 @@ import { Cline } from "../Cline" import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" +import { readWorkspaceFile, writeWorkspaceFile } from "../../integrations/misc/workspace-files" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -73,11 +74,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement configManager: ConfigManager customModesManager: CustomModesManager + private isCreator: boolean = false constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, + isCreator: boolean = false, ) { + this.outputChannel.appendLine(`creator = ${isCreator}`) + this.isCreator = isCreator + console.dir("CREATOR") + console.dir(this.isCreator) this.outputChannel.appendLine("ClineProvider instantiated") ClineProvider.activeInstances.add(this) this.workspaceTracker = new WorkspaceTracker(this) @@ -127,6 +134,16 @@ export class ClineProvider implements vscode.WebviewViewProvider { McpServerManager.unregisterProvider(this) } + public static getSidebarInstance(): ClineProvider | undefined { + const sidebar = Array.from(this.activeInstances).find((instance) => !instance.isCreator) + + if (!sidebar?.view?.visible) { + vscode.commands.executeCommand("pearai-roo-cline.SidebarProvider.focus") + } + + return sidebar + } + public static getVisibleInstance(): ClineProvider | undefined { return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true) } @@ -332,10 +349,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { const modePrompt = customModePrompts?.[mode] as PromptComponent const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n") - this.cline = new Cline({ provider: this, - apiConfiguration, + apiConfiguration: { + ...apiConfiguration, + creatorMode: mode === "creator", + }, customInstructions: effectiveInstructions, enableDiff: diffEnabled, enableCheckpoints: checkpointsEnabled, @@ -365,7 +384,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.cline = new Cline({ provider: this, - apiConfiguration, + apiConfiguration: { + ...apiConfiguration, + creatorMode: mode === "creator", + }, customInstructions: effectiveInstructions, enableDiff: diffEnabled, enableCheckpoints: checkpointsEnabled, @@ -414,6 +436,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { window.$RefreshReg$ = () => {} window.$RefreshSig$ = () => (type) => type window.__vite_plugin_react_preamble_installed__ = true + window.isCreator="${this.isCreator}"; ` @@ -426,6 +449,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { `connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort} http://localhost:8000 http://0.0.0.0:8000 https://stingray-app-gb2an.ondigitalocean.app`, ] + console.dir("CREATORRRRRR") + console.dir(this.isCreator) return /*html*/ ` @@ -1542,6 +1567,47 @@ export class ClineProvider implements vscode.WebviewViewProvider { ), ) break + case "readWorkspaceFile": + if (message.values?.relativePath) { + const result = await readWorkspaceFile(message.values.relativePath, { + create: message.values.create, + ensureDirectory: message.values.ensureDirectory, + content: message.values.content, + }) + await this.postMessageToWebview(result) + } + break + case "writeWorkspaceFile": + if (message.values?.relativePath && message.values?.content !== undefined) { + const result = await writeWorkspaceFile(message.values.relativePath, { + create: message.values.create, + ensureDirectory: message.values.ensureDirectory, + content: message.values.content, + }) + await this.postMessageToWebview(result) + } + break + case "invoke": + switch (message.invoke) { + case "sendMessage": + if (message.text) { + await this.cline?.ask("followup", message.text, false) + } + break + case "setChatBoxMessage": + if (message.text) { + await this.cline?.ask("followup", message.text, false) + } + break + case "executeCommand": + if (message.command) { + await vscode.commands.executeCommand(message.command, message.args) + } + break + default: + console.warn("Unknown invoke:", message.invoke) + } + break } }, null, @@ -1748,7 +1814,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("pearaiModelInfo", pearaiModelInfo), ]) if (this.cline) { - this.cline.api = buildApiHandler(apiConfiguration) + this.cline.api = buildApiHandler({ + ...apiConfiguration, + creatorMode: mode === "creator", + }) } } diff --git a/src/extension.ts b/src/extension.ts index 5dcfd3169e5..7ec50ee92f3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,7 +35,7 @@ export function activate(context: vscode.ExtensionContext) { context.globalState.update("allowedCommands", defaultCommands) } - const sidebarProvider = new ClineProvider(context, outputChannel) + const sidebarProvider = new ClineProvider(context, outputChannel, false) context.subscriptions.push( vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, { @@ -111,7 +111,7 @@ export function activate(context: vscode.ExtensionContext) { outputChannel.appendLine("Opening Roo Code in new tab") // (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event) // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts - const tabProvider = new ClineProvider(context, outputChannel) + const tabProvider = new ClineProvider(context, outputChannel, true) //const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) @@ -137,7 +137,7 @@ export function activate(context: vscode.ExtensionContext) { // Lock the editor group so clicking on files doesn't open them over the panel await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + // await vscode.commands.executeCommand("workbench.action.lockEditorGroup") } // context.subscriptions.push(vscode.commands.registerCommand("roo-cline.popoutButtonClicked", openClineInNewTab)) diff --git a/src/integrations/misc/workspace-files.ts b/src/integrations/misc/workspace-files.ts new file mode 100644 index 00000000000..283ab436cc9 --- /dev/null +++ b/src/integrations/misc/workspace-files.ts @@ -0,0 +1,99 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { ExtensionMessage } from '../../shared/ExtensionMessage'; + +export async function readWorkspaceFile(relativePath: string, options: { create?: boolean; ensureDirectory?: boolean; content?: string } = {}): Promise { + try { + // Get workspace root + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + console.log("Workspace root:", workspaceRoot); + + // If no workspace root, try to use the current working directory + const effectiveRoot = workspaceRoot || process.cwd(); + console.log("Effective root:", effectiveRoot); + + // Resolve the full path + const fullPath = path.join(effectiveRoot, relativePath); + console.log("Full path:", fullPath); + const uri = vscode.Uri.file(fullPath); + console.log("URI:", uri.toString()); + + // Check if file exists + try { + const fileContent = await vscode.workspace.fs.readFile(uri); + return { + type: "fileContent", + content: Buffer.from(fileContent).toString('utf8') + }; + } catch { + // File doesn't exist + if (!options.create) { + throw new Error("File does not exist"); + } + + // If we should create directories + if (options.ensureDirectory) { + const dirPath = path.dirname(fullPath); + try { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(dirPath)); + } catch (err) { + // Directory might already exist, that's fine + } + } + + // Create with provided content or empty string + const content = options.content || ""; + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf8")); + return { + type: "fileContent", + content + }; + } + } catch (error) { + return { + type: "error", + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function writeWorkspaceFile(relativePath: string, options: { create?: boolean; ensureDirectory?: boolean; content: string }): Promise { + try { + // Get workspace root + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + console.log("Workspace root (write):", workspaceRoot); + + // If no workspace root, try to use the current working directory + const effectiveRoot = workspaceRoot || process.cwd(); + console.log("Effective root (write):", effectiveRoot); + + // Resolve the full path + const fullPath = path.join(effectiveRoot, relativePath); + console.log("Full path (write):", fullPath); + const uri = vscode.Uri.file(fullPath); + console.log("URI (write):", uri.toString()); + + // If we should create directories + if (options.ensureDirectory) { + const dirPath = path.dirname(fullPath); + try { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(dirPath)); + } catch (err) { + // Directory might already exist, that's fine + } + } + + // Write the file + await vscode.workspace.fs.writeFile(uri, Buffer.from(options.content, "utf8")); + + return { + type: "fileContent", + content: options.content + }; + } catch (error) { + return { + type: "error", + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} \ No newline at end of file diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index e87edffed16..aa074953bbb 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -45,6 +45,9 @@ export interface ExtensionMessage { | "updateCustomMode" | "deleteCustomMode" | "currentCheckpointUpdated" + | "creator" + | "fileContent" + | "error" text?: string action?: | "chatButtonClicked" @@ -53,6 +56,7 @@ export interface ExtensionMessage { | "historyButtonClicked" | "promptsButtonClicked" | "didBecomeVisible" + | "generateActionPlan" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" state?: ExtensionState images?: string[] @@ -77,6 +81,8 @@ export interface ExtensionMessage { mode?: Mode customMode?: ModeConfig slug?: string + content?: string + error?: string } export interface ApiConfigMeta { @@ -194,6 +200,7 @@ export interface ClineSayTool { | "searchFiles" | "switchMode" | "newTask" + | "showTaskPlan" path?: string diff?: string content?: string diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 6f24415b624..3e7d1d70b9d 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -38,6 +38,8 @@ export interface WebviewMessage { | "requestLmStudioModels" | "openImage" | "openFile" + | "readWorkspaceFile" + | "writeWorkspaceFile" | "openMention" | "cancelTask" | "refreshOpenRouterModels" @@ -95,6 +97,9 @@ export interface WebviewMessage { | "openPearAiAuth" | "deleteMcpServer" | "maxOpenTabsContext" + | "creator" + | "generateActionPlan" + | "invoke" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -111,13 +116,21 @@ export interface WebviewMessage { promptMode?: PromptMode customPrompt?: PromptComponent dataUrls?: string[] - values?: Record + values?: { + create?: boolean + ensureDirectory?: boolean + content?: string + relativePath?: string + } & Record query?: string slug?: string modeConfig?: ModeConfig timeout?: number payload?: WebViewMessagePayload source?: "global" | "project" + invoke?: "sendMessage" | "setChatBoxMessage" | "executeCommand" + command?: string + args?: any } export const checkoutDiffPayloadSchema = z.object({ diff --git a/src/shared/api.ts b/src/shared/api.ts index c217f9121dc..408d7c1dddf 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -20,6 +20,7 @@ export type ApiProvider = export interface ApiHandlerOptions { apiModelId?: string + creatorMode?: boolean apiKey?: string // anthropic anthropicBaseUrl?: string anthropicThinking?: number diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 2f946283c0d..5f0a4ce1c35 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -40,7 +40,7 @@ export const experimentConfigsMap: Record = { name: 'Use experimental "power steering" mode', description: "When enabled, Roo will remind the model about the details of its current mode definition more frequently. This will lead to stronger adherence to role definitions and custom instructions, but will use more tokens per message.", - enabled: false, + enabled: true, }, } diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f7d3a3be3b7..4bc586d9774 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -74,6 +74,13 @@ export function getToolsForMode(groups: readonly GroupEntry[]): string[] { // Main modes configuration as an ordered array export const modes: readonly ModeConfig[] = [ + { + slug: "creator", + name: "Creator", + roleDefinition: + "You are PearAI Agent (Powered by Roo Code / Cline), a creative and systematic software architect focused on turning high-level ideas into actionable plans. Your primary goal is to help users transform their ideas into structured action plans.", + groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"], + }, { slug: "code", name: "Code", diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 740ceb11acc..5ed4c443c61 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -12,6 +12,7 @@ import WelcomeView from "./components/welcome/WelcomeView" import McpView from "./components/mcp/McpView" import PromptsView from "./components/prompts/PromptsView" import { Inspector } from "react-dev-inspector" +import Creator from "./creator/Creator" type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" @@ -83,10 +84,10 @@ const App = () => { ) } +const tempIsCreator: boolean = true + const AppWithProviders = () => ( - - - + {window.isCreator === "true" ? : } ) export default AppWithProviders diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index cd2773d0808..ccd98738e35 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1022,7 +1022,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie ...
-
PearAI Coding Agent
+
PearAI Casdfoding Agent
Powered by Roo Code / Cline
diff --git a/webview-ui/src/creator/ChatRow.tsx b/webview-ui/src/creator/ChatRow.tsx new file mode 100644 index 00000000000..07ad05ec471 --- /dev/null +++ b/webview-ui/src/creator/ChatRow.tsx @@ -0,0 +1,1129 @@ +import { CheckpointSaved } from "@/components/chat/checkpoints/CheckpointSaved" +import ReasoningBlock from "@/components/chat/ReasoningBlock" +import { highlightMentions } from "@/components/chat/TaskHeader" +import { removeLeadingNonAlphanumeric } from "@/components/common/CodeAccordian" +import CodeAccordian from "./CodeAccordian" +import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock" +import MarkdownBlock from "@/components/common/MarkdownBlock" +import Thumbnails from "@/components/common/Thumbnails" +import McpResourceRow from "@/components/mcp/McpResourceRow" +import McpToolRow from "@/components/mcp/McpToolRow" +import { vscEditorBackground } from "@/components/ui" +import { Tail2 } from "@/components/ui/tail" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { findMatchingResourceOrTemplate } from "@/utils/mcp" +import { vscode } from "@/utils/vscode" +import { ViewfinderCircleIcon } from "@heroicons/react/24/outline" +import { OpenInNewWindowIcon } from "@radix-ui/react-icons" +import { VSCodeProgressRing, VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { PencilIcon, ServerIcon, PlusCircleIcon } from "lucide-react" +import React, { memo, useRef, useEffect, useState, useMemo } from "react" +import { useSize } from "react-use" +import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences" +import { ClineMessage, ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool } from "../../../src/shared/ExtensionMessage" +import { useCopyToClipboard } from "@/utils/clipboard" +import deepEqual from "fast-deep-equal" + + +interface ChatRowProps { + message: ClineMessage + isExpanded: boolean + onToggleExpand: () => void + lastModifiedMessage: ClineMessage | undefined + isLast: boolean + onHeightChange: (isTaller: boolean) => void + isStreaming: boolean + onEditPlan?: (path: string) => void +} + +interface ChatRowContentProps extends Omit {} + +const ChatRow = memo( + (props: ChatRowProps) => { + const { isLast, onHeightChange, message } = props + // Store the previous height to compare with the current height + // This allows us to detect changes without causing re-renders + const prevHeightRef = useRef(0) + + const [chatrow, { height }] = useSize( +
+ +
, + ) + + useEffect(() => { + // used for partials, command output, etc. + // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete + const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that + // height starts off at Infinity + if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { + if (!isInitialRender) { + onHeightChange(height > prevHeightRef.current) + } + prevHeightRef.current = height + } + }, [height, isLast, onHeightChange, message]) + + // we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered + return chatrow + }, + // memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change + deepEqual, +) + +export default ChatRow + +export const ChatRowContent = ({ + message, + isExpanded, + onToggleExpand, + lastModifiedMessage, + isLast, + isStreaming, + onEditPlan, +}: ChatRowContentProps) => { + const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState() + const [reasoningCollapsed, setReasoningCollapsed] = useState(false) + + // Auto-collapse reasoning when new messages arrive + useEffect(() => { + if (!isLast && message.say === "reasoning") { + setReasoningCollapsed(true) + } + }, [isLast, message.say]) + const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { + if (message.text !== null && message.text !== undefined && message.say === "api_req_started") { + const info: ClineApiReqInfo = JSON.parse(message.text) + return [info.cost, info.cancelReason, info.streamingFailedMessage] + } + return [undefined, undefined, undefined] + }, [message.text, message.say]) + // when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything + const apiRequestFailedMessage = + isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried + ? lastModifiedMessage?.text + : undefined + const isCommandExecuting = + isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING) + + const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started" + + const type = message.type === "ask" ? message.ask : message.say + + const normalColor = "var(--vscode-foreground)" + const errorColor = "var(--vscode-errorForeground)" + const successColor = "var(--vscode-charts-green)" + const cancelledColor = "var(--vscode-descriptionForeground)" + + const [icon, title] = useMemo(() => { + switch (type) { + case "error": + return [ + , + Error, + ] + case "mistake_limit_reached": + return [ + , + Roo is having trouble..., + ] + case "command": + return [ + isCommandExecuting ? ( + + ) : ( + + ), + + Agent wants to execute this command: + , + ] + case "use_mcp_server": + const mcpServerUse = JSON.parse(message.text || "{}") as ClineAskUseMcpServer + return [ + isMcpServerResponding ? ( + + ) : ( + + ), + + Agent wants to {mcpServerUse.type === "use_mcp_tool" ? "use a tool" : "access a resource"} on + the {mcpServerUse.serverName} MCP server: + , + ] + case "completion_result": + return [ + // + // , +
+ + Task Completed +
, + ] + case "api_req_retry_delayed": + return [] + case "api_req_started": + const getIconSpan = (iconName: string, color: string) => ( +
+ +
+ ) + return [ + apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( + apiReqCancelReason === "user_cancelled" ? ( + getIconSpan("error", cancelledColor) + ) : ( + getIconSpan("error", errorColor) + ) + ) : cost !== null && cost !== undefined ? ( + getIconSpan("check", successColor) + ) : apiRequestFailedMessage ? ( + getIconSpan("error", errorColor) + ) : ( + + ), + apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( + apiReqCancelReason === "user_cancelled" ? ( + API Request Cancelled + ) : ( + API Streaming Failed + ) + ) : cost != null && cost !== undefined ? ( + <> + // + // + // API REQUEST + // + ) : apiRequestFailedMessage ? ( + API Request Failed + ) : ( + API Request... + ), + ] + case "followup": + return [ +
+ + ,Roo has a question:, +
, + ] + default: + return [null, null] + } + }, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage]) + + const headerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "10px", + marginBottom: "10px", + } + + const pStyle: React.CSSProperties = { + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + overflowWrap: "anywhere", + } + + const tool = useMemo(() => { + if (message.ask === "tool" || message.say === "tool") { + return JSON.parse(message.text || "{}") as ClineSayTool + } + return null + }, [message.ask, message.say, message.text]) + + if (tool) { + const toolIcon = (name: string) => ( + + ) + + switch (tool.tool) { + case "editedExistingFile": + case "appliedDiff": + return ( + <> +
+ +
+ Agent wants to edit + + + {removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"} + +
+
+ + + ) + case "newFileCreated": + return ( +
+ {/*
+ + Agent wants to create a new file +
*/} + +
+ ) + case "readFile": + return ( + <> + {/* */} +
+ {/* + // @ts-ignore */} + +
+ + {message.type === "ask" ? "Agent wants to read this file" : "Roo read this file:"} + +
{ + vscode.postMessage({ type: "openFile", text: tool.content }) + }}> + {tool.path?.startsWith(".") && .} + + {removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"} + +
+ {/* + */} +
+
+ { + vscode.postMessage({ type: "openFile", text: tool.content }) + }} + /> +
+ + ) + case "listFilesTopLevel": + return ( + <> +
+ {toolIcon("folder-opened")} + + {message.type === "ask" + ? "Agent wants to view the top level files in this directory:" + : "Roo viewed the top level files in this directory:"} + +
+ + + ) + case "listFilesRecursive": + return ( + <> +
+ {toolIcon("folder-opened")} + + {message.type === "ask" + ? "Agent wants to recursively view all files in this directory:" + : "Roo recursively viewed all files in this directory:"} + +
+ + + ) + case "listCodeDefinitionNames": + return ( + <> +
+ {toolIcon("file-code")} + + {message.type === "ask" + ? "Agent wants to view source code definition names used in this directory:" + : "Roo viewed source code definition names used in this directory:"} + +
+ + + ) + case "searchFiles": + return ( + <> +
+ {toolIcon("search")} + + {message.type === "ask" ? ( + <> + Agent wants to search this directory for {tool.regex}: + + ) : ( + <> + Roo searched this directory for {tool.regex}: + + )} + +
+ + + ) + // case "inspectSite": + // const isInspecting = + // isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images + // return ( + // <> + //
+ // {isInspecting ? : toolIcon("inspect")} + // + // {message.type === "ask" ? ( + // <>Agent wants to inspect this website: + // ) : ( + // <>Roo is inspecting this website: + // )} + // + //
+ //
+ // + //
+ // + // ) + case "switchMode": + return ( + <> +
+ {toolIcon("symbol-enum")} + + {message.type === "ask" ? ( + <> + Agent wants to switch to {tool.mode} mode + {tool.reason ? ` because: ${tool.reason}` : ""} + + ) : ( + <> + Roo switched to {tool.mode} mode + {tool.reason ? ` because: ${tool.reason}` : ""} + + )} + +
+ + ) + case "newTask": + return ( + <> +
+ {toolIcon("new-file")} + + Agent wants to create a new task in {tool.mode} mode: + +
+
+ {tool.content} +
+ + ) + default: + return null + } + } + + switch (message.type) { + case "say": + switch (message.say) { + case "reasoning": + return ( + setReasoningCollapsed(!reasoningCollapsed)} + /> + ) + case "api_req_started": + return ( + <> +
+ {/* 0 ? 1 : 0 }} className=""> */} +
+
+
+ {title}${Number(cost || 0)?.toFixed(4)} +
+ {/* */} +
+ {isStreaming && ( +
+ +
+ )} +
+ {/*
*/} +
+ {(((cost === null || cost === undefined) && apiRequestFailedMessage) || + apiReqStreamingFailedMessage) && ( + <> +

+ {apiRequestFailedMessage || apiReqStreamingFailedMessage} + {apiRequestFailedMessage?.toLowerCase().includes("powershell") && ( + <> +
+
+ It seems like you're having Windows PowerShell issues, please see this{" "} + + troubleshooting guide + + . + + )} +

+ + {/* {apiProvider === "" && ( +
+ + + Uh-oh, this could be a problem on end. We've been alerted and + will resolve this ASAP. You can also{" "} + + contact us + + . + +
+ )} */} + + )} + + {isExpanded && ( +
+ +
+ )} + + ) + case "api_req_finished": + return null // we should never see this message type + case "text": + return ( +
+ +
+ ) + case "user_feedback": + return ( +
+ +
+ + {highlightMentions(message.text)} + + + { + e.stopPropagation() + vscode.postMessage({ + type: "deleteMessage", + value: message.ts, + }) + }}> + + +
+ {message.images && message.images.length > 0 && ( + + )} +
+ ) + case "user_feedback_diff": + const tool = JSON.parse(message.text || "{}") as ClineSayTool + return ( +
+ +
+ ) + case "error": + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +

{message.text}

+ + ) + case "completion_result": + return ( +
+ {/*
+ {icon} + {title} +
*/} +
+ +
+
+ ) + case "shell_integration_warning": + return ( + <> +
+
+ + + Shell Integration Unavailable + +
+
+ Roo won't be able to view the command's output. Please update VSCode ( + CMD/CTRL + Shift + P → "Update") and make sure you're using a supported + shell: zsh, bash, fish, or PowerShell (CMD/CTRL + Shift + P → + "Terminal: Select Default Profile").{" "} + + Still having trouble? + +
+
+ + ) + case "mcp_server_response": + return ( + <> +
+
+ Response +
+ +
+ + ) + case "checkpoint_saved": + return ( + + ) + default: + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +
+ +
+ + ) + } + case "ask": + switch (message.ask) { + case "mistake_limit_reached": + return ( + <> +
+ {icon} + {title} +
+

{message.text}

+ + ) + case "command": + const splitMessage = (text: string) => { + const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING) + if (outputIndex === -1) { + return { command: text, output: "" } + } + return { + command: text.slice(0, outputIndex).trim(), + output: text + .slice(outputIndex + COMMAND_OUTPUT_STRING.length) + .trim() + .split("") + .map((char) => { + switch (char) { + case "\t": + return "→ " + case "\b": + return "⌫" + case "\f": + return "⏏" + case "\v": + return "⇳" + default: + return char + } + }) + .join(""), + } + } + + const { command, output } = splitMessage(message.text || "") + return ( + <> +
+ {icon} + {title} +
+ {/* 0} + /> */} +
+ + {output.length > 0 && ( +
+
+ + Command Output +
+ {isExpanded && } +
+ )} +
+ + ) + case "use_mcp_server": + const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer + const server = mcpServers.find((server) => server.name === useMcpServer.serverName) + return ( + <> +
+ {icon} + {title} +
+ +
+ {useMcpServer.type === "access_mcp_resource" && ( + + )} + + {useMcpServer.type === "use_mcp_tool" && ( + <> +
e.stopPropagation()}> + tool.name === useMcpServer.toolName, + )?.description || "", + alwaysAllow: + server?.tools?.find( + (tool) => tool.name === useMcpServer.toolName, + )?.alwaysAllow || false, + }} + serverName={useMcpServer.serverName} + alwaysAllowMcp={alwaysAllowMcp} + /> +
+ {useMcpServer.arguments && useMcpServer.arguments !== "{}" && ( +
+
+ Arguments +
+ +
+ )} + + )} +
+ + ) + case "completion_result": + if (message.text) { + return ( +
+
+ {icon} + {title} +
+
+ +
+
+ ) + } else { + return null // Don't render anything when we get a completion_result ask without text + } + case "followup": + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +
+ +
+ + ) + default: + return null + } + } +} + +export const ProgressIndicator = () => ( +
+
+ +
+
+) + +const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => { + const [isHovering, setIsHovering] = useState(false) + const { copyWithFeedback } = useCopyToClipboard(200) // shorter feedback duration for copy button flash + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + style={{ position: "relative" }}> +
+ +
+ {markdown && !partial && isHovering && ( +
+ + { + const success = await copyWithFeedback(markdown) + if (success) { + const button = document.activeElement as HTMLElement + if (button) { + button.style.background = "var(--vscode-button-background)" + setTimeout(() => { + button.style.background = "" + }, 200) + } + } + }} + title="Copy as markdown"> + + +
+ )} +
+ ) +}) diff --git a/webview-ui/src/creator/CodeAccordian.tsx b/webview-ui/src/creator/CodeAccordian.tsx new file mode 100644 index 00000000000..3b4edb288c8 --- /dev/null +++ b/webview-ui/src/creator/CodeAccordian.tsx @@ -0,0 +1,145 @@ +import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock" +import { getLanguageFromPath } from "@/utils/getLanguageFromPath" +import { useMemo, memo } from "react" +import { Button } from "@/components/ui" + + +interface CodeAccordianProps { + code?: string + diff?: string + language?: string | undefined + path?: string + isFeedback?: boolean + isConsoleLogs?: boolean + isExpanded: boolean + onToggleExpand: () => void + isLoading?: boolean + onEditPlan?: (path: string) => void +} + +/* +We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work. +^: Anchors the match to the start of the string. +[^a-zA-Z0-9]+: Matches one or more characters that are not alphanumeric. +The replace method removes these matched characters, effectively trimming the string up to the first alphanumeric character. +*/ +export const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[^a-zA-Z0-9]+/, "") + +const CodeAccordian = ({ + code, + diff, + language, + path, + isFeedback, + isConsoleLogs, + isExpanded, + onToggleExpand, + isLoading, + onEditPlan, +}: CodeAccordianProps) => { + const inferredLanguage = useMemo( + () => code && (language ?? (path ? getLanguageFromPath(path) : undefined)), + [path, language, code], + ) + + return ( +
+ {(path || isFeedback || isConsoleLogs) && ( +
+ {isFeedback || isConsoleLogs ? ( +
+ + + {isFeedback ? "User Edits" : "Console Logs"} + +
+ ) : ( + <> + {path?.startsWith(".") && .} + + {/* {removeLeadingNonAlphanumeric(path ?? "") + "\u200E"} */} + {path} + + + )} +
+ {path && !isFeedback && !isConsoleLogs && ( + + )} + +
+ )} + {(!(path || isFeedback || isConsoleLogs) || isExpanded) && ( +
+ +
+ )} +
+ ) +} + +// memo does shallow comparison of props, so if you need it to re-render when a nested object changes, you need to pass a custom comparison function +export default memo(CodeAccordian) diff --git a/webview-ui/src/creator/CodeBlock.tsx b/webview-ui/src/creator/CodeBlock.tsx new file mode 100644 index 00000000000..7b861f9043e --- /dev/null +++ b/webview-ui/src/creator/CodeBlock.tsx @@ -0,0 +1,161 @@ +import { useExtensionState } from "@/context/ExtensionStateContext" +import { memo, useEffect } from "react" +import { useRemark } from "react-remark" +import rehypeHighlight, { Options } from "rehype-highlight" +import styled from "styled-components" +import { visit } from "unist-util-visit" + +export const CODE_BLOCK_BG_COLOR = "var(--vscode-sideBar-background, --vscode-editor-background, rgb(30 30 30))" + +/* +overflowX: auto + inner div with padding results in an issue where the top/left/bottom padding renders but the right padding inside does not count as overflow as the width of the element is not exceeded. Once the inner div is outside the boundaries of the parent it counts as overflow. +https://stackoverflow.com/questions/60778406/why-is-padding-right-clipped-with-overflowscroll/77292459#77292459 +this fixes the issue of right padding clipped off +“ideal” size in a given axis when given infinite available space--allows the syntax highlighter to grow to largest possible width including its padding +minWidth: "max-content", +*/ + +interface CodeBlockProps { + source?: string + forceWrap?: boolean +} + +const StyledMarkdown = styled.div<{ forceWrap: boolean }>` + ${({ forceWrap }) => + forceWrap && + ` + pre, code { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + padding: 12px 12px; + } + `} + + pre { + background-color: ${CODE_BLOCK_BG_COLOR}; + border-radius: 5px; + margin: 0; + min-width: ${({ forceWrap }) => (forceWrap ? "auto" : "max-content")}; + padding: 10px 10px; + } + + pre > code { + .hljs-deletion { + background-color: var(--vscode-diffEditor-removedTextBackground); + display: inline-block; + width: 100%; + } + .hljs-addition { + background-color: var(--vscode-diffEditor-insertedTextBackground); + display: inline-block; + width: 100%; + } + } + + code { + span.line:empty { + display: none; + } + word-wrap: break-word; + border-radius: 5px; + background-color: ${CODE_BLOCK_BG_COLOR}; + font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); + font-family: var(--vscode-editor-font-family); + } + + code:not(pre > code) { + font-family: var(--vscode-editor-font-family); + color: #f78383; + } + + background-color: ${CODE_BLOCK_BG_COLOR}; + font-family: + var(--vscode-font-family), + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); + color: var(--vscode-editor-foreground, #fff); + + p, + li, + ol, + ul { + line-height: 1.5; + } +` + +const StyledPre = styled.pre<{ theme: any }>` + & .hljs { + color: var(--vscode-editor-foreground, #fff); + } + + ${(props) => + Object.keys(props.theme) + .map((key, index) => { + return ` + & ${key} { + color: ${props.theme[key]}; + } + ` + }) + .join("")} +` + +const CodeBlock = memo(({ source, forceWrap = false }: CodeBlockProps) => { + const { theme } = useExtensionState() + const [reactContent, setMarkdownSource] = useRemark({ + remarkPlugins: [ + () => { + return (tree) => { + visit(tree, "code", (node: any) => { + if (!node.lang) { + node.lang = "javascript" + } else if (node.lang.includes(".")) { + // if the language is a file, get the extension + node.lang = node.lang.split(".").slice(-1)[0] + } + }) + } + }, + ], + rehypePlugins: [ + rehypeHighlight as any, + { + // languages: {}, + } as Options, + ], + rehypeReactOptions: { + components: { + pre: ({ node, ...preProps }: any) => , + }, + }, + }) + + useEffect(() => { + setMarkdownSource(source || "") + }, [source, setMarkdownSource, theme]) + + return ( +
+ {reactContent} +
+ ) +}) + +export default CodeBlock diff --git a/webview-ui/src/creator/SplitView.tsx b/webview-ui/src/creator/SplitView.tsx new file mode 100644 index 00000000000..8f15430f197 --- /dev/null +++ b/webview-ui/src/creator/SplitView.tsx @@ -0,0 +1,252 @@ +import React, { useEffect, useState, useCallback } from "react" +import styled from "styled-components" +import { vscEditorBackground, vscButtonBackground, vscBackground } from "@/components/ui" +import { vscode } from "@/utils/vscode" +import CodeBlock from "./CodeBlock" +import { getLanguageFromPath, normalizePath } from "@/utils/getLanguageFromPath" +import { WebviewMessage } from "../../../src/shared/WebviewMessage" + +interface SplitViewProps { + filePath: string // This is workspace-relative path + onClose: () => void +} + +interface FileContentMessage { + type: "fileContent" | "error" + content?: string + error?: string +} + +const SplitViewContainer = styled.div` + padding: 12px 12px; + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 50%; + background-color: ${vscEditorBackground}; + overflow: hidden; + display: flex; + flex-direction: column; + z-index: 1000; + margin: 12px 12px; + // border: 2px solid red; + border-radius: 12px; +` + +const Header = styled.div` + display: flex; + align-items: center; + padding: 12px; + border-bottom: 1px solid var(--vscode-editorGroup-border); +` + +const Title = styled.div` + flex-grow: 1; + font-weight: bold; + margin-right: 12px; +` + +const Content = styled.div` + flex-grow: 1; + overflow: auto; + margin: 12px 0px; + border-radius: 12px; +` + +const StyledButton = styled.button` + background-color: ${vscButtonBackground}; + color: var(--vscode-button-foreground); + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + margin-left: 8px; + + &:hover { + opacity: 0.9; + } +` + +const EmptyFileMessage = styled.div` + color: var(--vscode-descriptionForeground); + font-style: italic; + padding: 8px; + text-align: center; + border: 1px dashed var(--vscode-editorGroup-border); + border-radius: 4px; + margin: 8px 0; +` + +const SplitView: React.FC = ({ filePath, onClose }) => { + const [content, setContent] = useState("") + const [isEditing, setIsEditing] = useState(false) + const [editedContent, setEditedContent] = useState("") + const [error, setError] = useState(null) + + // Ensure the path has the correct directory structure for display + const normalizedPath = normalizePath(filePath) + + const handleMessage = useCallback((event: MessageEvent) => { + const message = event.data + console.log("SplitView received message:", message) + + if (message.type === "fileContent") { + if (message.content !== undefined) { + console.log("Setting content:", message.content) + // Use functional updates to access the current state + setIsEditing((currentIsEditing) => { + if (currentIsEditing) { + // This is a save confirmation + setContent(message.content) + setEditedContent(message.content) + console.log("Save operation completed successfully") + return false // Exit edit mode + } else { + // Initial file load + setContent(message.content) + setEditedContent(message.content) + return currentIsEditing // Keep current edit state + } + }) + setError(null) + } + } else if (message.type === "error") { + console.log("Received error:", message.error) + setIsEditing((currentIsEditing) => { + if (!currentIsEditing) { + // Only clear content if this was a read operation + console.log("Read operation failed, clearing content") + setContent("") + setEditedContent("") + } else { + console.log("Save operation failed") + } + return currentIsEditing // Keep current edit state + }) + setError(message.error || "Operation failed") + } + }, []) // Remove isEditing from dependencies + + // Log when the component mounts and unmounts + useEffect(() => { + console.log("SplitView mounted with filepath:", filePath) + return () => { + console.log("SplitView unmounted") + } + }, [filePath]) + + useEffect(() => { + console.log("Setting up message listener and initiating file read") + window.addEventListener("message", handleMessage) + + // Read the file + console.log("Sending readWorkspaceFile message for path:", filePath) + try { + vscode.postMessage({ + type: "readWorkspaceFile", + values: { + relativePath: filePath, + create: true, + ensureDirectory: true, + content: "", + }, + }) + console.log("Successfully sent readWorkspaceFile message") + } catch (error) { + console.error("Error sending readWorkspaceFile message:", error) + setError("Failed to send file read request") + } + + // Cleanup + return () => { + console.log("Cleaning up message listener") + window.removeEventListener("message", handleMessage) + } + }, [filePath, handleMessage]) + + const handleSave = useCallback(() => { + console.log("Initiating save for file:", filePath) + console.log("Content length:", editedContent.length) + + try { + // Don't exit edit mode until we get confirmation + const message: WebviewMessage = { + type: "writeWorkspaceFile", + values: { + relativePath: filePath, + create: true, + ensureDirectory: true, + content: editedContent, + }, + } + vscode.postMessage(message) + console.log("Successfully sent writeWorkspaceFile message") + } catch (error) { + console.error("Error sending writeWorkspaceFile message:", error) + setError("Failed to send file save request") + } + }, [filePath, editedContent]) + + const language = getLanguageFromPath(normalizedPath) + + return ( + +
+ {normalizedPath} + {isEditing ? ( + <> + Save + setIsEditing(false)}>Cancel + + ) : ( + setIsEditing(true)}>Edit + )} + Close +
+ + {error && !isEditing && ( +
{error}
+ )} + {isEditing ? ( + <> + {!editedContent && ( + + File is empty. Start typing to add content to {normalizedPath} + + )} +