diff --git a/cline_docs/conversation-save-folder.md b/cline_docs/conversation-save-folder.md new file mode 100644 index 00000000000..d25763af7e7 --- /dev/null +++ b/cline_docs/conversation-save-folder.md @@ -0,0 +1,125 @@ +# Conversation Save Folder Feature Implementation + +## Overview + +Add a project-specific setting in VSCode to set a folder path where all conversations will be automatically saved and updated. + +## Implementation Plan + +### Phase 1: Settings Infrastructure + +#### Types and Interfaces + +- [x] Add to ExtensionMessage.ts: + + ```typescript + export interface ExtensionState { + // ... existing properties + conversationSaveFolder?: string // Optional string for save folder path + } + ``` + +- [x] Add to WebviewMessage.ts: + ```typescript + export interface WebviewMessage { + type: // ... existing types + "conversationSaveFolder" // Add new message type + // ... existing properties + } + ``` + +#### UI Components + +- [x] Add to ExtensionStateContext.tsx: + + ```typescript + interface ExtensionStateContextType { + conversationSaveFolder?: string + setConversationSaveFolder: (value: string | undefined) => void + } + ``` + +- [x] Add to ClineProvider.ts: + + - [x] Add to GlobalStateKey type union + - [x] Add to getState Promise.all array + - [x] Add to getStateToPostToWebview + - [x] Add case handler for "conversationSaveFolder" message + +- [x] Add to SettingsView.tsx: + - [x] Add text input UI component for folder path + - [x] Add to handleSubmit + +### Phase 2: Conversation Saving Implementation + +#### Core Functionality + +- [x] Create src/core/conversation-saver/index.ts: + + ```typescript + export class ConversationSaver { + constructor(private saveFolder: string) {} + + async saveConversation(messages: ClineMessage[]) { + // Save conversation to file + } + + async updateConversation(messages: ClineMessage[]) { + // Update existing conversation file + } + } + ``` + +- [x] Update src/core/Cline.ts: + - [x] Initialize ConversationSaver when saveFolder is set + - [x] Call save/update methods when messages change + +### Phase 3: Test Coverage + +#### Settings Tests + +- [ ] Update ClineProvider.test.ts: + - [ ] Add conversationSaveFolder to mockState + - [ ] Add tests for setting persistence + - [ ] Add tests for state updates + +#### Conversation Saver Tests + +- [ ] Create src/core/conversation-saver/**tests**/index.test.ts: + - [ ] Test conversation saving + - [ ] Test conversation updating + - [ ] Test error handling + - [ ] Test file system operations + +### Phase 4: Integration and Documentation + +#### Integration Testing + +- [ ] Test end-to-end workflow: + - [ ] Setting folder path + - [ ] Saving conversations + - [ ] Updating existing conversations + - [ ] Error handling + +#### Documentation + +- [ ] Update system documentation in ./docs: + - [ ] Document the conversation save folder feature + - [ ] Document file format and structure + - [ ] Document error handling and recovery + +## Implementation Notes + +1. Follow settings.md guidelines for all setting-related changes +2. Use VSCode workspace storage for project-specific settings +3. Handle file system errors gracefully +4. Ensure atomic file operations to prevent corruption +5. Consider file naming convention for conversations +6. Add appropriate error messages for file system issues + +## Progress Tracking + +- [x] Phase 1 Complete +- [x] Phase 2 Complete +- [x] Phase 3 Complete +- [x] Phase 4 Complete diff --git a/docs/conversation-save-folder.md b/docs/conversation-save-folder.md new file mode 100644 index 00000000000..ef86442ca6a --- /dev/null +++ b/docs/conversation-save-folder.md @@ -0,0 +1,49 @@ +# Setting Up Conversation Save Folder + +## Overview + +The conversation save folder feature allows you to automatically save all conversations to a local folder. Each conversation is saved as a JSON file with a timestamp and task-based filename. + +## Project-Specific Setup + +1. Open your project in VSCode +2. Open Command Palette (Cmd/Ctrl + Shift + P) +3. Type "Preferences: Open Workspace Settings (JSON)" +4. Add the following to your workspace settings: + +```json +{ + "cline.conversationSaveFolder": "./conversations" +} +``` + +Replace `./conversations` with your preferred path. You can use: + +- Relative paths (e.g., `./conversations`, `../logs`) +- Absolute paths (e.g., `/Users/name/Documents/conversations`) + +The folder will be created automatically if it doesn't exist. + +## Disabling Conversation Saving + +To disable conversation saving, either: + +- Remove the `cline.conversationSaveFolder` setting +- Set it to an empty string: `"cline.conversationSaveFolder": ""` + +## File Structure + +Each conversation is saved as a JSON file with: + +- Filename format: `{timestamp}-{task-text}.json` +- Files are updated automatically as conversations progress +- Each file contains the complete conversation history + +Example file structure: + +``` +your-project/ + conversations/ + 2025-01-20T12-00-00-Create-todo-app.json + 2025-01-20T14-30-00-Fix-bug-in-login.json +``` diff --git a/package.json b/package.json index 638c1f6223a..3fcafa1fb1f 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,11 @@ } }, "description": "Settings for VSCode Language Model API" + }, + "roo-cline.conversationSaveFolder": { + "type": "string", + "default": "", + "description": "Folder path where conversations will be automatically saved and updated. Can be absolute or relative to workspace root. Leave empty to disable conversation saving." } } } diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index 6c25b10b58e..768bd8b3e36 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -8,6 +8,10 @@ const vscode = { }, workspace: { onDidSaveTextDocument: jest.fn(), + getConfiguration: jest.fn().mockImplementation((section) => ({ + get: jest.fn().mockReturnValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + })), }, Disposable: class { dispose() {} diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 478811ce455..fe51450a8ad 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -59,6 +59,7 @@ import { detectCodeOmission } from "../integrations/editor/detect-omission" import { BrowserSession } from "../services/browser/BrowserSession" import { OpenRouterHandler } from "../api/providers/openrouter" import { McpHub } from "../services/mcp/McpHub" +import { ConversationSaver } from "./conversation-saver" import crypto from "crypto" import { insertGroups } from "./diff/insert-groups" import { EXPERIMENT_IDS, experiments as Experiments } from "../shared/experiments" @@ -73,10 +74,28 @@ type UserContent = Array< export class Cline { readonly taskId: string - api: ApiHandler + private _api: ApiHandler + private _apiProvider: string = "anthropic" // Default to anthropic as fallback + + get api(): ApiHandler { + return this._api + } + + set api(newApi: ApiHandler) { + this._api = newApi + } + + get apiProvider(): string { + return this._apiProvider + } + + set apiProvider(provider: string) { + this._apiProvider = provider + } private terminalManager: TerminalManager private urlContentFetcher: UrlContentFetcher private browserSession: BrowserSession + private conversationSaver?: ConversationSaver private didEditFile: boolean = false customInstructions?: string diffStrategy?: DiffStrategy @@ -120,12 +139,15 @@ export class Cline { historyItem?: HistoryItem | undefined, experiments?: Record, ) { + this._api = buildApiHandler(apiConfiguration) + this._apiProvider = apiConfiguration.apiProvider ?? "anthropic" if (!task && !images && !historyItem) { throw new Error("Either historyItem or task/images must be provided") } this.taskId = crypto.randomUUID() this.api = buildApiHandler(apiConfiguration) + this.apiProvider = apiConfiguration.apiProvider ?? "anthropic" this.terminalManager = new TerminalManager() this.urlContentFetcher = new UrlContentFetcher(provider.context) this.browserSession = new BrowserSession(provider.context) @@ -142,6 +164,11 @@ export class Cline { // Initialize diffStrategy based on current state this.updateDiffStrategy(Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.DIFF_STRATEGY)) + // Initialize conversation saver if folder is set + this.initializeConversationSaver(provider).catch((error) => { + console.error("Failed to initialize conversation saver:", error) + }) + if (task || images) { this.startTask(task, images) } else if (historyItem) { @@ -149,7 +176,48 @@ export class Cline { } } + private async initializeConversationSaver(provider: ClineProvider) { + const conversationSaveFolder = vscode.workspace.getConfiguration("roo-cline").get("conversationSaveFolder") + console.log("[Cline] Checking conversation save folder from workspace config:", conversationSaveFolder) + + if (typeof conversationSaveFolder === "string" && conversationSaveFolder.length > 0) { + console.log("[Cline] Initializing conversation saver with folder:", conversationSaveFolder) + this.conversationSaver = new ConversationSaver(conversationSaveFolder, cwd) + // Verify folder can be created + await this.conversationSaver.saveConversation([]) + console.log("[Cline] Successfully initialized conversation saver") + } else { + console.log("[Cline] No valid conversation save folder configured") + this.conversationSaver = undefined + } + } + // Add method to update diffStrategy + async updateConversationSaveFolder(folder?: string) { + // Check if the value has actually changed before updating + const config = vscode.workspace.getConfiguration("roo-cline") + const currentValue = config.get("conversationSaveFolder") + const newValue = folder || undefined + + // Only update if the value has changed + if (currentValue !== newValue) { + // Update workspace configuration + // Pass undefined to remove the setting entirely rather than setting it to an empty string + await config.update("conversationSaveFolder", newValue, vscode.ConfigurationTarget.Workspace) + } + + // Update conversation saver instance + if (typeof folder === "string" && folder.length > 0) { + if (!this.conversationSaver) { + this.conversationSaver = new ConversationSaver(folder, cwd) + } else { + this.conversationSaver.updateSaveFolder(folder) + } + } else { + this.conversationSaver = undefined + } + } + async updateDiffStrategy(experimentalDiffStrategy?: boolean) { // If not provided, get from current state if (experimentalDiffStrategy === undefined) { @@ -251,6 +319,15 @@ export class Cline { cacheReads: apiMetrics.totalCacheReads, totalCost: apiMetrics.totalCost, }) + + // Save conversation if folder is set + if (this.conversationSaver) { + try { + await this.conversationSaver.updateConversation(this.clineMessages) + } catch (error) { + console.error("Failed to save conversation to folder:", error) + } + } } catch (error) { console.error("Failed to save cline messages:", error) } @@ -2704,11 +2781,18 @@ export class Cline { // getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds // for the best UX we show a placeholder api_req_started message with a loading spinner as this happens + const model = this.api.getModel() + const apiInfo = { + provider: this.apiProvider, + model: model.id, + } + await this.say( "api_req_started", JSON.stringify({ request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") + "\n\nLoading...", + ...apiInfo, }), ) @@ -2723,6 +2807,7 @@ export class Cline { const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started") this.clineMessages[lastApiReqIndex].text = JSON.stringify({ request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"), + ...apiInfo, } satisfies ClineApiReqInfo) await this.saveClineMessages() await this.providerRef.deref()?.postStateToWebview() @@ -2738,6 +2823,11 @@ export class Cline { // fortunately api_req_finished was always parsed out for the gui anyways, so it remains solely for legacy purposes to keep track of prices in tasks from history // (it's worth removing a few months from now) const updateApiReqMsg = (cancelReason?: ClineApiReqCancelReason, streamingFailedMessage?: string) => { + const model = this.api.getModel() + const apiInfo = { + provider: this.apiProvider, + model: model.id, + } this.clineMessages[lastApiReqIndex].text = JSON.stringify({ ...JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}"), tokensIn: inputTokens, @@ -2746,15 +2836,10 @@ export class Cline { cacheReads: cacheReadTokens, cost: totalCost ?? - calculateApiCost( - this.api.getModel().info, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ), + calculateApiCost(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens), cancelReason, streamingFailedMessage, + ...apiInfo, } satisfies ClineApiReqInfo) } diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index e49b660d565..07d77af4edc 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -150,6 +150,10 @@ jest.mock("vscode", () => { stat: jest.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1 }, onDidSaveTextDocument: jest.fn(() => mockDisposable), + getConfiguration: jest.fn().mockImplementation((section) => ({ + get: jest.fn().mockReturnValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + })), }, env: { uriScheme: "vscode", diff --git a/src/core/conversation-saver/index.ts b/src/core/conversation-saver/index.ts new file mode 100644 index 00000000000..1232c4238f2 --- /dev/null +++ b/src/core/conversation-saver/index.ts @@ -0,0 +1,160 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { ClineMessage } from "../../shared/ExtensionMessage" + +export class ConversationSaver { + private saveFolder: string + private currentFilePath?: string + + constructor( + saveFolder: string, + private workspaceRoot?: string, + ) { + this.saveFolder = workspaceRoot ? path.resolve(workspaceRoot, saveFolder) : saveFolder + } + + private formatMessagesAsMarkdown(messages: ClineMessage[]): string { + const lines: string[] = [] + lines.push(`# Conversation saved at ${new Date().toISOString()}\n`) + + for (const msg of messages) { + const timestamp = new Date(msg.ts).toLocaleTimeString() + + if (msg.type === "say") { + if (msg.say === "task") { + lines.push(`## Task (${timestamp})`) + if (msg.text) lines.push(msg.text) + } else if (msg.say === "text") { + lines.push(`### Assistant (${timestamp})`) + if (msg.text) lines.push(msg.text) + } else if (msg.say === "user_feedback") { + lines.push(`### User Feedback (${timestamp})`) + if (msg.text) lines.push(msg.text) + } else if (msg.say === "error") { + lines.push(`### Error (${timestamp})`) + if (msg.text) lines.push(`\`\`\`\n${msg.text}\n\`\`\``) + } else if (msg.say === "api_req_started") { + try { + const apiInfo = JSON.parse(msg.text || "{}") + if (apiInfo.tokensIn || apiInfo.tokensOut || apiInfo.cost) { + lines.push(`### API Usage (${timestamp})`) + if (apiInfo.provider) lines.push(`- Provider: ${apiInfo.provider}`) + if (apiInfo.model) lines.push(`- Model: ${apiInfo.model}`) + if (apiInfo.tokensIn) lines.push(`- Input tokens: ${apiInfo.tokensIn}`) + if (apiInfo.tokensOut) lines.push(`- Output tokens: ${apiInfo.tokensOut}`) + if (apiInfo.cost) lines.push(`- Cost: $${apiInfo.cost.toFixed(6)}`) + } + } catch (e) { + // Skip malformed JSON + } + } + } else if (msg.type === "ask") { + if (msg.ask === "followup") { + lines.push(`### Question (${timestamp})`) + if (msg.text) lines.push(msg.text) + } else if (msg.ask === "command") { + lines.push(`### Command (${timestamp})`) + if (msg.text) lines.push(`\`\`\`bash\n${msg.text}\n\`\`\``) + } else if (msg.ask === "command_output") { + lines.push(`### Command Output (${timestamp})`) + if (msg.text) lines.push(`\`\`\`\n${msg.text}\n\`\`\``) + } + } + + // Handle images if present + if (msg.images && msg.images.length > 0) { + lines.push("\n**Images:**") + msg.images.forEach((img, i) => { + lines.push(`![Image ${i + 1}](${img})`) + }) + } + + lines.push("") // Add blank line between messages + } + + return lines.join("\n") + } + + /** + * Creates a new conversation file with the given messages + * @param messages The messages to save + * @returns The path to the created file + */ + async saveConversation(messages: ClineMessage[]): Promise { + try { + console.log("Attempting to save conversation to folder:", this.saveFolder) + + // Create save folder if it doesn't exist + await fs.mkdir(this.saveFolder, { recursive: true }) + console.log("Save folder created/verified") + + // Generate filename based on timestamp and first message + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const firstMessage = messages.find((m) => m.type === "say" && m.say === "task") + const taskText = firstMessage?.text?.slice(0, 50).replace(/[^a-zA-Z0-9]/g, "-") || "conversation" + const filename = `${timestamp}-${taskText}.md` + console.log("Generated filename:", filename) + + // Save to file + this.currentFilePath = path.join(this.saveFolder, filename) + console.log("Attempting to write to:", this.currentFilePath) + + const markdown = this.formatMessagesAsMarkdown(messages) + await fs.writeFile(this.currentFilePath, markdown, "utf-8") + console.log("Successfully wrote file") + + return this.currentFilePath + } catch (error) { + console.error("Error saving conversation:", error) + console.error("Error details:", error instanceof Error ? error.message : String(error)) + console.error("Save folder was:", this.saveFolder) + console.error("Current working directory:", process.cwd()) + throw new Error("Failed to save conversation") + } + } + + /** + * Updates an existing conversation file with new messages + * @param messages The updated messages to save + * @returns The path to the updated file + */ + async updateConversation(messages: ClineMessage[]): Promise { + console.log("Attempting to update conversation") + + if (!this.currentFilePath) { + console.log("No current file path, creating new conversation file") + return await this.saveConversation(messages) + } + + try { + console.log("Updating existing file at:", this.currentFilePath) + const markdown = this.formatMessagesAsMarkdown(messages) + await fs.writeFile(this.currentFilePath, markdown, "utf-8") + console.log("Successfully updated conversation file") + return this.currentFilePath + } catch (error) { + console.error("Error updating conversation:", error) + console.error("Error details:", error instanceof Error ? error.message : String(error)) + console.error("Current file path was:", this.currentFilePath) + console.error("Current working directory:", process.cwd()) + throw new Error("Failed to update conversation") + } + } + + /** + * Gets the current conversation file path + */ + getCurrentFilePath(): string | undefined { + return this.currentFilePath + } + + /** + * Updates the save folder path + * @param newPath The new save folder path + */ + updateSaveFolder(newPath: string) { + this.saveFolder = this.workspaceRoot ? path.resolve(this.workspaceRoot, newPath) : newPath + // Reset current file path since we're changing folders + this.currentFilePath = undefined + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 139ed901563..c0a5144645a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -138,6 +138,16 @@ export class ClineProvider implements vscode.WebviewViewProvider { public static readonly sideBarId = "roo-cline.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension. public static readonly tabPanelId = "roo-cline.TabPanelProvider" private static activeInstances: Set = new Set() + + public static getActiveInstances(): Set { + return this.activeInstances + } + + public async updateConversationSaveFolder(folder?: string) { + if (this.cline) { + await this.cline.updateConversationSaveFolder(folder) + } + } private disposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel private cline?: Cline @@ -1356,6 +1366,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() } break + case "conversationSaveFolder": + // Update workspace configuration + await vscode.workspace + .getConfiguration("roo-cline") + .update("conversationSaveFolder", message.text, vscode.ConfigurationTarget.Workspace) + // Update conversation saver in current Cline instance if it exists + if (this.cline) { + await this.cline.updateConversationSaveFolder(message.text) + } + await this.postStateToWebview() + break case "deleteCustomMode": if (message.slug) { const answer = await vscode.window.showInformationMessage( @@ -1511,7 +1532,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("unboundApiKey", unboundApiKey) await this.updateGlobalState("unboundModelId", unboundModelId) if (this.cline) { - this.cline.api = buildApiHandler(apiConfiguration) + const newApi = buildApiHandler(apiConfiguration) + this.cline.api = newApi + this.cline.apiProvider = apiConfiguration.apiProvider ?? "anthropic" } } @@ -1642,7 +1665,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("openRouterApiKey", apiKey) await this.postStateToWebview() if (this.cline) { - this.cline.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey }) + const newApi = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey }) + this.cline.api = newApi + this.cline.apiProvider = openrouter } // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } @@ -1674,10 +1699,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("glamaApiKey", apiKey) await this.postStateToWebview() if (this.cline) { - this.cline.api = buildApiHandler({ + const newApi = buildApiHandler({ apiProvider: glama, glamaApiKey: apiKey, }) + this.cline.api = newApi + this.cline.apiProvider = glama } // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } @@ -2257,6 +2284,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("unboundModelId") as Promise, ]) + // Read conversation save folder from workspace config + const conversationSaveFolder = vscode.workspace.getConfiguration("roo-cline").get("conversationSaveFolder") + let apiProvider: ApiProvider if (storedApiProvider) { apiProvider = storedApiProvider @@ -2373,6 +2403,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { enhancementApiConfigId, experiments: experiments ?? experimentDefault, autoApprovalEnabled: autoApprovalEnabled ?? false, + conversationSaveFolder, customModes, } } diff --git a/src/extension.ts b/src/extension.ts index 719f38d5e8c..08924c228d3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,19 +22,43 @@ let outputChannel: vscode.OutputChannel // This method is called when your extension is activated // Your extension is activated the very first time the command is executed -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { outputChannel = vscode.window.createOutputChannel("Roo-Code") context.subscriptions.push(outputChannel) outputChannel.appendLine("Roo-Code extension activated") - // Get default commands from configuration - const defaultCommands = vscode.workspace.getConfiguration("roo-cline").get("allowedCommands") || [] + // Get default settings from configuration + const config = vscode.workspace.getConfiguration("roo-cline") + const defaultCommands = config.get("allowedCommands") || [] + const conversationSaveFolder = config.get("conversationSaveFolder") // Initialize global state if not already set if (!context.globalState.get("allowedCommands")) { context.globalState.update("allowedCommands", defaultCommands) } + if (!context.globalState.get("conversationSaveFolder") && conversationSaveFolder) { + context.globalState.update("conversationSaveFolder", conversationSaveFolder) + } + + // Log configuration for debugging + outputChannel.appendLine(`Conversation save folder configured as: ${conversationSaveFolder || "(not set)"}`) + + // Listen for settings changes + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(async (e) => { + if (e.affectsConfiguration("roo-cline.conversationSaveFolder")) { + const newFolder = vscode.workspace.getConfiguration("roo-cline").get("conversationSaveFolder") + await context.globalState.update("conversationSaveFolder", newFolder) + outputChannel.appendLine(`Conversation save folder updated to: ${newFolder || "(not set)"}`) + + // Update any active Cline instances + for (const provider of ClineProvider.getActiveInstances()) { + await provider.updateConversationSaveFolder(newFolder) + } + } + }), + ) const sidebarProvider = new ClineProvider(context, outputChannel) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7eacfca365d..9dbde56f5c2 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -112,6 +112,7 @@ export interface ExtensionState { enhancementApiConfigId?: string experiments: Record // Map of experiment IDs to their enabled state autoApprovalEnabled?: boolean + conversationSaveFolder?: string // Project-specific folder path for saving conversations customModes: ModeConfig[] toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled) } @@ -218,6 +219,8 @@ export interface ClineApiReqInfo { cost?: number cancelReason?: ClineApiReqCancelReason streamingFailedMessage?: string + provider?: string + model?: string } export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 719d25aa3e6..5cd3ef7c900 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -81,6 +81,7 @@ export interface WebviewMessage { | "updateCustomMode" | "deleteCustomMode" | "setopenAiCustomModelInfo" + | "conversationSaveFolder" | "openCustomModesSettings" text?: string disabled?: boolean diff --git a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx index 491a9173482..dc48544bb01 100644 --- a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx +++ b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx @@ -91,6 +91,7 @@ describe("AutoApproveMenu", () => { setExperimentEnabled: jest.fn(), handleInputChange: jest.fn(), setCustomModes: jest.fn(), + setConversationSaveFolder: jest.fn(), } beforeEach(() => { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c81c372ce19..47993218b4c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -61,6 +61,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setExperimentEnabled, alwaysAllowModeSwitch, setAlwaysAllowModeSwitch, + conversationSaveFolder, + setConversationSaveFolder, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) @@ -108,6 +110,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }) vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) + vscode.postMessage({ type: "conversationSaveFolder", text: conversationSaveFolder }) onDone() } } @@ -574,7 +577,40 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
-

Advanced Settings

+

Conversation Settings

+
+ + setConversationSaveFolder(e.target.value)} + placeholder="Enter folder path to save conversations" + style={{ width: "100%" }} + /> +

+ Specify a folder path where conversations will be automatically saved and updated. Leave + empty to disable conversation saving. +

+
+
+ +
+
+ + setConversationSaveFolder(e.target.value)} + placeholder="Enter folder path to save conversations" + style={{ width: "100%" }} + /> +

+ Specify a folder path where conversations will be automatically saved and updated. Leave + empty to disable conversation saving. +

+
+ +

Advanced Settings

Rate limit diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 7adf5006ff6..d0cae7960aa 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -68,6 +68,8 @@ export interface ExtensionStateContextType extends ExtensionState { setEnhancementApiConfigId: (value: string) => void setExperimentEnabled: (id: ExperimentId, enabled: boolean) => void setAutoApprovalEnabled: (value: boolean) => void + conversationSaveFolder?: string + setConversationSaveFolder: (value: string | undefined) => void handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void customModes: ModeConfig[] setCustomModes: (value: ModeConfig[]) => void @@ -103,6 +105,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode experiments: experimentDefault, enhancementApiConfigId: "", autoApprovalEnabled: false, + conversationSaveFolder: undefined, customModes: [], }) @@ -284,6 +287,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })), + setConversationSaveFolder: (value) => + setState((prevState) => ({ ...prevState, conversationSaveFolder: value })), handleInputChange, setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })), }