Add slack webhook functionality

This commit is contained in:
ColemanRoo 2025-01-10 09:48:36 -06:00
parent 0100d6cff5
commit cda6773132
9 changed files with 646 additions and 153 deletions

View file

@ -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")

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@ -1625,6 +1732,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
this.getGlobalState("slackWebhookUrl") as Promise<string | undefined>,
this.getGlobalState("slackNotificationsEnabled") as Promise<boolean | undefined>,
])
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" })

View file

@ -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<void> {
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<void> {
await this.sendMessage(`✅ Task Complete: ${taskDescription}`)
}
async notifyUserInputNeeded(question: string): Promise<void> {
await this.sendMessage(`❓ User Input Received: ${question}`)
}
async notifyTaskFailed(error: string): Promise<void> {
await this.sendMessage(`❌ Task Failed: ${error}`)
}
}

View file

@ -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

View file

@ -42,6 +42,8 @@ export interface WebviewMessage {
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "slackNotificationsEnabled"
| "slackWebhookUrl"
| "browserViewportSize"
| "screenshotQuality"
| "openMcpSettings"

77
src/test/slack.test.ts Normal file
View file

@ -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()
})
})

View file

@ -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
}

View file

@ -58,18 +58,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setRequestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
slackWebhookUrl,
setSlackWebhookUrl,
slackNotificationsEnabled,
setSlackNotificationsEnabled,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(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) => {
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
<VSCodeButton onClick={() => {
handleSubmit();
}}>Done</VSCodeButton>
</div>
<div
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
@ -641,6 +662,41 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</div>
</div>
)}
<div style={{ marginTop: 15 }}>
<VSCodeCheckbox checked={slackNotificationsEnabled ?? false} onChange={(e: any) => setSlackNotificationsEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable Slack notifications</span>
</VSCodeCheckbox>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will send notifications to Slack for important events.
</p>
{slackNotificationsEnabled && (
<div style={{ marginTop: 10 }}>
<VSCodeTextField
value={slackWebhookUrl}
style={{ width: "100%" }}
placeholder="https://hooks.slack.com/services/..."
onInput={(e: any) => {
setSlackWebhookUrl(e.target.value)
vscode.postMessage({ type: "slackWebhookUrl", text: e.target.value })
}}>
<span style={{ fontWeight: "500" }}>Slack Webhook URL</span>
</VSCodeTextField>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Enter your Slack Incoming Webhook URL to receive notifications.
</p>
</div>
)}
</div>
</div>
</div>

View file

@ -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<ExtensionStateContextType | undefined>(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<ExtensionMessage>) => {
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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>