diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 6cbc925013..788b5fddb2 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -51,6 +51,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 { SlackNotifier } from "../services/slack" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution @@ -66,6 +67,7 @@ export class Cline { private terminalManager: TerminalManager private urlContentFetcher: UrlContentFetcher private browserSession: BrowserSession + private slackNotifier?: SlackNotifier private didEditFile: boolean = false customInstructions?: string diffStrategy?: DiffStrategy @@ -96,6 +98,28 @@ export class Cline { private didAlreadyUseTool = false private didCompleteReadingStream = false + private async notifySlack(type: 'complete' | 'input' | 'fail', message: string) { + if (!this.slackNotifier) { + return; + } + + try { + switch (type) { + case 'complete': + await this.slackNotifier.notifyTaskComplete(message); + break; + case 'input': + await this.slackNotifier.notifyUserInputNeeded(message); + break; + case 'fail': + await this.slackNotifier.notifyTaskFailed(message); + break; + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to send Slack notification: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + constructor( provider: ClineProvider, apiConfiguration: ApiConfiguration, @@ -105,12 +129,40 @@ export class Cline { task?: string | undefined, images?: string[] | undefined, historyItem?: HistoryItem | undefined, + slackConfig?: { enabled: boolean; webhookUrl: string } ) { + // Set taskId first + if (historyItem) { + this.taskId = historyItem.id + } else if (task || images) { + this.taskId = Date.now().toString() + } else { + throw new Error("Either historyItem or task/images must be provided") + } + this.providerRef = new WeakRef(provider) this.api = buildApiHandler(apiConfiguration) this.terminalManager = new TerminalManager() this.urlContentFetcher = new UrlContentFetcher(provider.context) this.browserSession = new BrowserSession(provider.context) + + // Initialize Slack notifier + if (slackConfig?.enabled && slackConfig?.webhookUrl) { + try { + this.slackNotifier = new SlackNotifier(slackConfig); + + // Send initialization notification + const initMessage = task + ? `🚀 New task started: ${task}` + : historyItem + ? `📝 Resuming task: ${historyItem.task}` + : "🔄 Roo Cline initialized"; + + this.notifySlack('input', initMessage).catch(error => { + }); + } catch (error) { + } + } this.diffViewProvider = new DiffViewProvider(cwd) this.customInstructions = customInstructions this.diffEnabled = enableDiff ?? false @@ -118,13 +170,9 @@ export class Cline { this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0) } if (historyItem) { - this.taskId = historyItem.id this.resumeTaskFromHistory() } else if (task || images) { - this.taskId = Date.now().toString() this.startTask(task, images) - } else { - throw new Error("Either historyItem or task/images must be provided") } } @@ -166,7 +214,6 @@ export class Cline { await fs.writeFile(filePath, JSON.stringify(this.apiConversationHistory)) } catch (error) { // in the off chance this fails, we don't want to stop the task - console.error("Failed to save API conversation history:", error) } } @@ -221,7 +268,6 @@ export class Cline { totalCost: apiMetrics.totalCost, }) } catch (error) { - console.error("Failed to save cline messages:", error) } } @@ -233,6 +279,11 @@ export class Cline { text?: string, partial?: boolean, ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { + // Send notification for user input needed + if (type === "followup" && text && !partial) { + await this.notifySlack('input', text) + } + // If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.) if (this.abort) { throw new Error("Cline instance aborted") @@ -313,10 +364,13 @@ export class Cline { } await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + if (this.lastMessageTs !== askTs) { throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully } + const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } + this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined @@ -891,6 +945,8 @@ export class Cline { } const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too + + switch (block.type) { case "text": { if (this.didRejectTool || this.didAlreadyUseTool) { @@ -1049,10 +1105,9 @@ export class Cline { const handleError = async (action: string, error: Error) => { const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}` - await this.say( - "error", - `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, - ) + const errorMessage = `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` + await this.say("error", errorMessage) + await this.notifySlack('fail', errorMessage) // this.toolResults.push({ // type: "tool_result", // tool_use_id: toolUseId, @@ -2003,13 +2058,62 @@ export class Cline { commandResult = execCommandResult } else { await this.say("completion_result", result, undefined, false) + await (async () => { + try { + if (!result) { + console.warn("No result provided for completion notification", { + taskId: this.taskId, + timestamp: new Date().toISOString() + }); + } + + console.log("About to call notifySlack with type 'complete'", { + resultLength: result?.length, + taskId: this.taskId, + hasSlackNotifier: !!this.slackNotifier, + slackConfig: this.slackNotifier?.config, + timestamp: new Date().toISOString() + }); + + const completionMessage = result + ? `✅ Task completed successfully!\n\nResult:\n${result}` + : "✅ Task completed successfully!"; + + // Make sure to await the notification + await this.notifySlack('complete', completionMessage); + + console.log("Successfully sent completion notification to Slack", { + taskId: this.taskId, + messageLength: completionMessage.length, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error("Error during notifySlack call:", { + errorMessage: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : 'No stack trace', + taskId: this.taskId + }); + vscode.window.showErrorMessage(`Failed to send Slack completion notification: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + })(); } // we already sent completion_result says, an empty string asks relinquishes control over button and field - const { response, text, images } = await this.ask("completion_result", "", false) + let askResponse; + try { + askResponse = await this.ask("completion_result", "", false); + } catch (error) { + console.error("Error during ask call:", { + error, + stack: error instanceof Error ? error.stack : 'No stack trace', + taskId: this.taskId + }); + throw error; + } + const { response, text, images } = askResponse; if (response === "yesButtonClicked") { - pushToolResult("") // signals to recursive loop to stop (for now this never happens since yesButtonClicked will trigger a new task) - break + pushToolResult(""); + break; } await this.say("user_feedback", text ?? "", images) @@ -2173,7 +2277,6 @@ export class Cline { // lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list lastMessage.partial = false // instead of streaming partialMessage events, we do a save and post like normal to persist to disk - console.log("updating partial message", lastMessage) // await this.saveClineMessages() } @@ -2240,7 +2343,6 @@ export class Cline { } if (this.abort) { - console.log("aborting stream...") if (!this.abandoned) { // only need to gracefully abort if this instance isn't abandoned (sometimes openrouter stream hangs, in which case this would affect future instances of cline) await abortStream("user_cancelled") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index da75d2769a..584de2b4aa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -89,6 +89,9 @@ type GlobalStateKey = | "requestDelaySeconds" | "currentApiConfigName" | "listApiConfigMeta" + | "slackConfig" + | "slackWebhookUrl" + | "slackNotificationsEnabled" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -234,45 +237,64 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.outputChannel.appendLine("Webview view resolved") } - async initClineWithTask(task?: string, images?: string[]) { - await this.clearTask() - const { - apiConfiguration, - customInstructions, - diffEnabled, - fuzzyMatchThreshold - } = await this.getState() + private async initClineWithTask(task?: string, images?: string[]): Promise { + try { + await this.clearTask(); + const { + apiConfiguration, + customInstructions, + diffEnabled, + fuzzyMatchThreshold, + slackConfig + } = await this.getState(); - this.cline = new Cline( - this, - apiConfiguration, - customInstructions, - diffEnabled, - fuzzyMatchThreshold, - task, - images - ) + this.cline = new Cline( + this, + apiConfiguration, + customInstructions, + diffEnabled, + fuzzyMatchThreshold, + task, + images, + undefined, + { + enabled: slackConfig?.enabled ?? false, + webhookUrl: slackConfig?.webhookUrl ?? "" + } + ); + } catch (error) { + throw new Error(`Failed to initialize Cline with task: ${error instanceof Error ? error.message : 'Unknown error'}`); + } } - async initClineWithHistoryItem(historyItem: HistoryItem) { - await this.clearTask() - const { - apiConfiguration, - customInstructions, - diffEnabled, - fuzzyMatchThreshold - } = await this.getState() + public async initClineWithHistoryItem(historyItem: HistoryItem): Promise { + try { + await this.clearTask(); + const { + apiConfiguration, + customInstructions, + diffEnabled, + fuzzyMatchThreshold, + slackConfig + } = await this.getState(); - this.cline = new Cline( - this, - apiConfiguration, - customInstructions, - diffEnabled, - fuzzyMatchThreshold, - undefined, - undefined, - historyItem - ) + this.cline = new Cline( + this, + apiConfiguration, + customInstructions, + diffEnabled, + fuzzyMatchThreshold, + undefined, + undefined, + historyItem, + { + enabled: slackConfig?.enabled ?? false, + webhookUrl: slackConfig?.webhookUrl ?? "" + } + ); + } catch (error) { + throw new Error(`Failed to initialize Cline with history item: ${error instanceof Error ? error.message : 'Unknown error'}`); + } } // Send any JSON serializable data to the react app @@ -656,6 +678,29 @@ export class ClineProvider implements vscode.WebviewViewProvider { setSoundVolume(soundVolume) await this.postStateToWebview() break + case "slackNotificationsEnabled": + const enabled = message.bool ?? false; + try { + await this.updateGlobalState("slackNotificationsEnabled", enabled); + // Also update slackConfig to keep settings in sync + const currentState = await this.getState(); + await this.updateGlobalState("slackConfig", { + enabled: enabled, + webhookUrl: currentState.slackWebhookUrl + }); + await this.postStateToWebview(); + } catch (error) { + vscode.window.showErrorMessage(`Failed to update Slack notifications setting: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + break + case "slackWebhookUrl": + await this.updateGlobalState("slackWebhookUrl", message.text ?? "") + await this.updateGlobalState("slackConfig", { + enabled: (await this.getState()).slackNotificationsEnabled ?? false, + webhookUrl: message.text ?? "" + }) + await this.postStateToWebview() + break case "diffEnabled": const diffEnabled = message.bool ?? true await this.updateGlobalState("diffEnabled", diffEnabled) @@ -912,81 +957,102 @@ export class ClineProvider implements vscode.WebviewViewProvider { ) } - private async updateApiConfiguration(apiConfiguration: ApiConfiguration) { - const { - apiProvider, - apiModelId, - apiKey, - glamaModelId, - glamaModelInfo, - glamaApiKey, - openRouterApiKey, - awsAccessKey, - awsSecretKey, - awsSessionToken, - awsRegion, - awsUseCrossRegionInference, - vertexProjectId, - vertexRegion, - openAiBaseUrl, - openAiApiKey, - openAiModelId, - ollamaModelId, - ollamaBaseUrl, - lmStudioModelId, - lmStudioBaseUrl, - anthropicBaseUrl, - geminiApiKey, - openAiNativeApiKey, - deepSeekApiKey, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterModelInfo, - openRouterUseMiddleOutTransform, - } = apiConfiguration - await this.updateGlobalState("apiProvider", apiProvider) - await this.updateGlobalState("apiModelId", apiModelId) - await this.storeSecret("apiKey", apiKey) - await this.updateGlobalState("glamaModelId", glamaModelId) - await this.updateGlobalState("glamaModelInfo", glamaModelInfo) - await this.storeSecret("glamaApiKey", glamaApiKey) - await this.storeSecret("openRouterApiKey", openRouterApiKey) - await this.storeSecret("awsAccessKey", awsAccessKey) - await this.storeSecret("awsSecretKey", awsSecretKey) - await this.storeSecret("awsSessionToken", awsSessionToken) - await this.updateGlobalState("awsRegion", awsRegion) - await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference) - await this.updateGlobalState("vertexProjectId", vertexProjectId) - await this.updateGlobalState("vertexRegion", vertexRegion) - await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl) - await this.storeSecret("openAiApiKey", openAiApiKey) - await this.updateGlobalState("openAiModelId", openAiModelId) - await this.updateGlobalState("ollamaModelId", ollamaModelId) - await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl) - await this.updateGlobalState("lmStudioModelId", lmStudioModelId) - await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl) - await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl) - await this.storeSecret("geminiApiKey", geminiApiKey) - await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) - await this.storeSecret("deepSeekApiKey", deepSeekApiKey) - await this.updateGlobalState("azureApiVersion", azureApiVersion) - await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled) - await this.updateGlobalState("openRouterModelId", openRouterModelId) - await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) - await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) - if (this.cline) { - this.cline.api = buildApiHandler(apiConfiguration) - } + private async updateApiConfiguration(apiConfiguration: ApiConfiguration): Promise { + try { + // Destructure all configuration values + const { + apiProvider, + apiModelId, + apiKey, + glamaModelId, + glamaModelInfo, + glamaApiKey, + openRouterApiKey, + awsAccessKey, + awsSecretKey, + awsSessionToken, + awsRegion, + awsUseCrossRegionInference, + vertexProjectId, + vertexRegion, + openAiBaseUrl, + openAiApiKey, + openAiModelId, + ollamaModelId, + ollamaBaseUrl, + lmStudioModelId, + lmStudioBaseUrl, + anthropicBaseUrl, + geminiApiKey, + openAiNativeApiKey, + deepSeekApiKey, + azureApiVersion, + openAiStreamingEnabled, + openRouterModelId, + openRouterModelInfo, + openRouterUseMiddleOutTransform, + } = apiConfiguration; + + // Update all state values in parallel for better performance + await Promise.all([ + // Update global state values + this.updateGlobalState("apiProvider", apiProvider), + this.updateGlobalState("apiModelId", apiModelId), + this.updateGlobalState("glamaModelId", glamaModelId), + this.updateGlobalState("glamaModelInfo", glamaModelInfo), + this.updateGlobalState("awsRegion", awsRegion), + this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference), + this.updateGlobalState("vertexProjectId", vertexProjectId), + this.updateGlobalState("vertexRegion", vertexRegion), + this.updateGlobalState("openAiBaseUrl", openAiBaseUrl), + this.updateGlobalState("openAiModelId", openAiModelId), + this.updateGlobalState("ollamaModelId", ollamaModelId), + this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl), + this.updateGlobalState("lmStudioModelId", lmStudioModelId), + this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl), + this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl), + this.updateGlobalState("azureApiVersion", azureApiVersion), + this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled), + this.updateGlobalState("openRouterModelId", openRouterModelId), + this.updateGlobalState("openRouterModelInfo", openRouterModelInfo), + this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform), + + // Store secrets + this.storeSecret("apiKey", apiKey), + this.storeSecret("glamaApiKey", glamaApiKey), + this.storeSecret("openRouterApiKey", openRouterApiKey), + this.storeSecret("awsAccessKey", awsAccessKey), + this.storeSecret("awsSecretKey", awsSecretKey), + this.storeSecret("awsSessionToken", awsSessionToken), + this.storeSecret("openAiApiKey", openAiApiKey), + this.storeSecret("geminiApiKey", geminiApiKey), + this.storeSecret("openAiNativeApiKey", openAiNativeApiKey), + this.storeSecret("deepSeekApiKey", deepSeekApiKey) + ]); + + // Update Cline instance if it exists + if (this.cline) { + this.cline.api = buildApiHandler(apiConfiguration); + } + } catch (error) { + throw new Error(`Failed to update API configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); + } } - async updateCustomInstructions(instructions?: string) { - // User may be clearing the field - await this.updateGlobalState("customInstructions", instructions || undefined) - if (this.cline) { - this.cline.customInstructions = instructions || undefined + async updateCustomInstructions(instructions?: string): Promise { + try { + // User may be clearing the field + const normalizedInstructions = instructions || undefined; + await this.updateGlobalState("customInstructions", normalizedInstructions); + + if (this.cline) { + this.cline.customInstructions = normalizedInstructions; + } + + await this.postStateToWebview(); + } catch (error) { + throw new Error(`Failed to update custom instructions: ${error instanceof Error ? error.message : 'Unknown error'}`); } - await this.postStateToWebview() } // MCP @@ -1332,12 +1398,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) if (fileExists) { const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) - return { - historyItem, - taskDirPath, - apiConversationHistoryFilePath, - uiMessagesFilePath, - apiConversationHistory, + return { + historyItem, + taskDirPath, + apiConversationHistoryFilePath, + uiMessagesFilePath, + apiConversationHistory, } } } @@ -1362,15 +1428,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async deleteTaskWithId(id: string) { - if (id === this.cline?.taskId) { + if (id === this.cline?.taskId) { await this.clearTask() - } + } const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id) await this.deleteTaskFromState(id) - // Delete the task files + // Delete the task files const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath) if (apiConversationHistoryFileExists) { await fs.unlink(apiConversationHistoryFilePath) @@ -1387,12 +1453,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async deleteTaskFromState(id: string) { - // Remove the task from history + // Remove the task from history const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || [] const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) await this.updateGlobalState("taskHistory", updatedTaskHistory) - // Notify the webview that the task has been deleted + // Notify the webview that the task has been deleted await this.postStateToWebview() } @@ -1426,6 +1492,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, + slackWebhookUrl, + slackNotificationsEnabled, } = await this.getState() const allowedCommands = vscode.workspace @@ -1462,12 +1530,18 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], + slackWebhookUrl: slackWebhookUrl ?? "", + slackNotificationsEnabled: slackNotificationsEnabled ?? false, + slackConfig: { + enabled: slackNotificationsEnabled ?? false, + webhookUrl: slackWebhookUrl ?? "" + }, } } async clearTask() { this.cline?.abortTask() - this.cline = undefined // removes reference to it, so once promises end it will be garbage collected + this.cline = undefined // removes reference to it, so once promises end it will be garbage collected } // Caching mechanism to keep track of webview messages + API conversation history per provider instance @@ -1516,7 +1590,38 @@ export class ClineProvider implements vscode.WebviewViewProvider { https://www.eliostruyf.com/devhack-code-extension-storage-options/ */ - async getState() { + public async getState(): Promise<{ + apiConfiguration: ApiConfiguration; + lastShownAnnouncementId?: string; + customInstructions?: string; + alwaysAllowReadOnly: boolean; + alwaysAllowWrite: boolean; + alwaysAllowExecute: boolean; + alwaysAllowBrowser: boolean; + alwaysAllowMcp: boolean; + taskHistory?: HistoryItem[]; + allowedCommands?: string[]; + soundEnabled: boolean; + diffEnabled: boolean; + soundVolume?: number; + browserViewportSize: string; + screenshotQuality: number; + fuzzyMatchThreshold: number; + writeDelayMs: number; + terminalOutputLineLimit: number; + slackWebhookUrl: string; + slackNotificationsEnabled: boolean; + slackConfig: { + enabled: boolean; + webhookUrl: string; + }; + preferredLanguage: string; + mcpEnabled: boolean; + alwaysApproveResubmit: boolean; + requestDelaySeconds: number; + currentApiConfigName: string; + listApiConfigMeta: ApiConfigMeta[]; + }> { const [ storedApiProvider, apiModelId, @@ -1571,6 +1676,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, + slackWebhookUrl, + slackNotificationsEnabled, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1625,6 +1732,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestDelaySeconds") as Promise, this.getGlobalState("currentApiConfigName") as Promise, this.getGlobalState("listApiConfigMeta") as Promise, + this.getGlobalState("slackWebhookUrl") as Promise, + this.getGlobalState("slackNotificationsEnabled") as Promise, ]) let apiProvider: ApiProvider @@ -1691,6 +1800,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, writeDelayMs: writeDelayMs ?? 1000, terminalOutputLineLimit: terminalOutputLineLimit ?? 500, + slackWebhookUrl: slackWebhookUrl ?? "", + slackNotificationsEnabled: slackNotificationsEnabled ?? false, + slackConfig: { + enabled: slackNotificationsEnabled ?? false, + webhookUrl: slackWebhookUrl ?? "" + }, preferredLanguage: preferredLanguage ?? (() => { // Get VSCode's locale setting const vscodeLang = vscode.env.language; @@ -1789,25 +1904,25 @@ export class ClineProvider implements vscode.WebviewViewProvider { for (const key of this.context.globalState.keys()) { await this.context.globalState.update(key, undefined) } - const secretKeys: SecretKey[] = [ - "apiKey", - "glamaApiKey", - "openRouterApiKey", - "awsAccessKey", - "awsSecretKey", - "awsSessionToken", - "openAiApiKey", - "geminiApiKey", - "openAiNativeApiKey", - "deepSeekApiKey", + const secretKeys: SecretKey[] = [ + "apiKey", + "glamaApiKey", + "openRouterApiKey", + "awsAccessKey", + "awsSecretKey", + "awsSessionToken", + "openAiApiKey", + "geminiApiKey", + "openAiNativeApiKey", + "deepSeekApiKey", ] for (const key of secretKeys) { await this.storeSecret(key, undefined) } - if (this.cline) { + if (this.cline) { this.cline.abortTask() this.cline = undefined - } + } vscode.window.showInformationMessage("State reset") await this.postStateToWebview() await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) diff --git a/src/services/slack/index.ts b/src/services/slack/index.ts new file mode 100644 index 0000000000..f7a5e696f0 --- /dev/null +++ b/src/services/slack/index.ts @@ -0,0 +1,94 @@ +import * as vscode from 'vscode' + +export interface SlackConfig { + webhookUrl: string + enabled: boolean +} + +export class SlackNotifier { + public readonly config: SlackConfig + + constructor(config: SlackConfig) { + console.log("Creating new SlackNotifier instance with config:", { + enabled: config.enabled, + hasWebhookUrl: !!config.webhookUrl, + webhookUrlLength: config.webhookUrl?.length + }); + this.config = config; + } + + private async sendMessage(text: string): Promise { + console.log("SlackNotifier.sendMessage called with:", { + text, + config: { + enabled: this.config.enabled, + hasWebhookUrl: !!this.config.webhookUrl, + webhookUrlLength: this.config.webhookUrl?.length + } + }); + + if (!this.config.enabled) { + console.log("Slack notifications are disabled in config"); + return; + } + + if (!this.config.webhookUrl) { + console.log("No Slack webhook URL configured in config"); + return; + } + + try { + console.log("Preparing Slack webhook request..."); + const body = JSON.stringify({ text }); + console.log("Request body prepared:", { bodyLength: body.length }); + + console.log("Sending request to Slack webhook..."); + const response = await fetch(this.config.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ text }) + }) + + console.log("Received response from Slack webhook:", { + status: response.status, + statusText: response.statusText, + ok: response.ok + }); + + if (!response.ok) { + const responseText = await response.text(); + console.error("Slack API error response:", { + status: response.status, + statusText: response.statusText, + responseText, + webhookUrlLength: this.config.webhookUrl.length, + messageLength: text.length + }); + throw new Error(`Failed to send Slack message: ${response.statusText} (${response.status})`); + } + + console.log("Successfully sent Slack message:", { + messageLength: text.length, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Error sending Slack notification:', error); + vscode.window.showErrorMessage(`Failed to send Slack notification: ${error instanceof Error ? error.message : 'Unknown error'}`); + // Don't throw - we don't want Slack errors to interrupt the main flow + } + } + + async notifyTaskComplete(taskDescription: string): Promise { + await this.sendMessage(`✅ Task Complete: ${taskDescription}`) + } + + async notifyUserInputNeeded(question: string): Promise { + await this.sendMessage(`❓ User Input Received: ${question}`) + } + + async notifyTaskFailed(error: string): Promise { + await this.sendMessage(`❌ Task Failed: ${error}`) + } +} \ No newline at end of file diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 8972958f5e..5bc2ba736b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -24,7 +24,13 @@ export interface ExtensionMessage { | "enhancedPrompt" | "commitSearchResults" | "listApiConfig" + | "soundEnabled" + | "soundVolume" + | "slackNotificationsEnabled" + | "slackWebhookUrl" text?: string + bool?: boolean + value?: number action?: | "chatButtonClicked" | "mcpButtonClicked" @@ -59,6 +65,12 @@ export interface ExtensionState { apiConfiguration?: ApiConfiguration currentApiConfigName?: string listApiConfigMeta?: ApiConfigMeta[] + slackWebhookUrl?: string + slackNotificationsEnabled?: boolean + slackConfig?: { + webhookUrl: string + enabled: boolean + } customInstructions?: string alwaysAllowReadOnly?: boolean alwaysAllowWrite?: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4072526209..d9975260d4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -42,6 +42,8 @@ export interface WebviewMessage { | "soundEnabled" | "soundVolume" | "diffEnabled" + | "slackNotificationsEnabled" + | "slackWebhookUrl" | "browserViewportSize" | "screenshotQuality" | "openMcpSettings" diff --git a/src/test/slack.test.ts b/src/test/slack.test.ts new file mode 100644 index 0000000000..dac6a5217e --- /dev/null +++ b/src/test/slack.test.ts @@ -0,0 +1,77 @@ +import { SlackNotifier } from '../services/slack' + +describe('SlackNotifier', () => { + let slackNotifier: SlackNotifier + let mockFetch: jest.Mock + + beforeEach(() => { + mockFetch = jest.fn() + global.fetch = mockFetch + slackNotifier = new SlackNotifier({ + webhookUrl: 'https://hooks.slack.com/services/test', + enabled: true + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should send task completion notification', async () => { + await slackNotifier.notifyTaskComplete('Task completed successfully') + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/test', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: '✅ Task Complete: Task completed successfully' }) + }) + ) + }) + + it('should send user input needed notification', async () => { + await slackNotifier.notifyUserInputNeeded('What is your preference?') + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/test', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: '❓ User Input Received: What is your preference?' }) + }) + ) + }) + + it('should send task failed notification', async () => { + await slackNotifier.notifyTaskFailed('Error occurred') + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/test', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: '❌ Task Failed: Error occurred' }) + }) + ) + }) + + it('should not send notifications when disabled', async () => { + slackNotifier = new SlackNotifier({ + webhookUrl: 'https://hooks.slack.com/services/test', + enabled: false + }) + + await slackNotifier.notifyTaskComplete('Task completed') + await slackNotifier.notifyUserInputNeeded('Input needed') + await slackNotifier.notifyTaskFailed('Task failed') + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should handle fetch errors gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + // These should not throw errors + await expect(slackNotifier.notifyTaskComplete('Task completed')).resolves.not.toThrow() + await expect(slackNotifier.notifyUserInputNeeded('Input needed')).resolves.not.toThrow() + await expect(slackNotifier.notifyTaskFailed('Task failed')).resolves.not.toThrow() + }) +}) \ No newline at end of file diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 12a7e93d24..a20cd4acb5 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -327,8 +327,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) break case "completion_result": + // First send the completion approval, then start new task + vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }); // Increase delay to ensure notification is processed + break; case "resume_completed_task": - // extension waiting for feedback. but we can just present a new task button startNewTask() break } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index af3bc5ccd1..399d9d594d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -58,18 +58,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setRequestDelaySeconds, currentApiConfigName, listApiConfigMeta, + slackWebhookUrl, + setSlackWebhookUrl, + slackNotificationsEnabled, + setSlackNotificationsEnabled, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") const handleSubmit = () => { + console.log('handleSubmit called'); + console.log('Validating configuration...'); const apiValidationResult = validateApiConfiguration(apiConfiguration) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) + console.log('Validation results:', { apiValidationResult, modelIdValidationResult }); setApiErrorMessage(apiValidationResult) setModelIdErrorMessage(modelIdValidationResult) if (!apiValidationResult && !modelIdValidationResult) { + console.log('Validation passed, sending messages...'); vscode.postMessage({ type: "apiConfiguration", apiConfiguration @@ -94,6 +102,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) + // Send settings to extension + console.log('Sending settings to extension...'); + console.log('Sound enabled:', soundEnabled); + console.log('Sound volume:', soundVolume); + console.log('Slack notifications enabled:', slackNotificationsEnabled); + console.log('Slack webhook URL:', slackWebhookUrl); + + vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }); + vscode.postMessage({ type: "soundVolume", value: soundVolume }); + vscode.postMessage({ type: "slackNotificationsEnabled", bool: slackNotificationsEnabled }); + vscode.postMessage({ type: "slackWebhookUrl", text: slackWebhookUrl }); vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, @@ -157,7 +176,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }}>

Settings

- Done + { + handleSubmit(); + }}>Done
@@ -641,6 +662,41 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
)} + +
+ setSlackNotificationsEnabled(e.target.checked)}> + Enable Slack notifications + +

+ When enabled, Cline will send notifications to Slack for important events. +

+ + {slackNotificationsEnabled && ( +
+ { + setSlackWebhookUrl(e.target.value) + vscode.postMessage({ type: "slackWebhookUrl", text: e.target.value }) + }}> + Slack Webhook URL + +

+ Enter your Slack Incoming Webhook URL to receive notifications. +

+
+ )} +
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 48a0757fd7..6992612b1a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -56,6 +56,8 @@ export interface ExtensionStateContextType extends ExtensionState { setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ApiConfigMeta[]) => void onUpdateApiConfig: (apiConfig: ApiConfiguration) => void + setSlackWebhookUrl: (value: string) => void + setSlackNotificationsEnabled: (value: boolean) => void } export const ExtensionStateContext = createContext(undefined) @@ -81,6 +83,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode requestDelaySeconds: 5, currentApiConfigName: 'default', listApiConfigMeta: [], + slackWebhookUrl: "", + slackNotificationsEnabled: false, }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -107,8 +111,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }) }, [state]) - const handleMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data + const handleMessage = useCallback((event: MessageEvent) => { + const message = event.data switch (message.type) { case "state": { setState(message.state!) @@ -171,6 +175,25 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setListApiConfigMeta(message.listApiConfig ?? []) break } + case "soundEnabled": { + if (message.bool !== undefined) { + setState((prevState: ExtensionState) => ({ ...prevState, soundEnabled: message.bool })) + } + break + } + case "soundVolume": { + if (message.value !== undefined) { + setState((prevState: ExtensionState) => ({ ...prevState, soundVolume: message.value })) + } + break + } + case "slackWebhookUrl": { + if (message.text !== undefined) { + setState((prevState: ExtensionState) => ({ ...prevState, slackWebhookUrl: message.text })) + vscode.postMessage({ type: "slackWebhookUrl", text: message.text }) + } + break + } } }, [setListApiConfigMeta]) @@ -194,6 +217,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, screenshotQuality: state.screenshotQuality, + slackWebhookUrl: state.slackWebhookUrl, + slackNotificationsEnabled: state.slackNotificationsEnabled, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value @@ -220,7 +245,15 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), setListApiConfigMeta, - onUpdateApiConfig + onUpdateApiConfig, + setSlackWebhookUrl: (value) => { + setState((prevState) => ({ ...prevState, slackWebhookUrl: value })) + vscode.postMessage({ type: "slackWebhookUrl", text: value }) + }, + setSlackNotificationsEnabled: (value) => { + setState((prevState) => ({ ...prevState, slackNotificationsEnabled: value })) + vscode.postMessage({ type: "slackNotificationsEnabled", bool: value }) + } } return {children}