From 63c766f380fd925d4656a5bb095d61989d9bf150 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Sun, 15 Dec 2024 19:47:13 -0800 Subject: [PATCH 01/12] fix soundEnabled init bug --- src/core/webview/ClineProvider.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e998332..15aa32a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -136,6 +136,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.outputChannel.appendLine("Resolving webview view") this.view = webviewView + // Initialize sound enabled state + this.getState().then(({ soundEnabled }) => { + setSoundEnabled(soundEnabled ?? false) + }) + webviewView.webview.options = { // Allow scripts in the webview enableScripts: true, From 09934e20f7bddc4d45ab92a618b838d828d92732 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Sun, 15 Dec 2024 23:13:32 -0800 Subject: [PATCH 02/12] only play sounds on errors, task completion, or when user intervention is needed --- webview-ui/src/components/chat/ChatView.tsx | 181 ++++++++++---------- 1 file changed, 90 insertions(+), 91 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index e4e0880..777425e 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -64,7 +64,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const [isAtBottom, setIsAtBottom] = useState(false) const [wasStreaming, setWasStreaming] = useState(false) - const [hasStarted, setHasStarted] = useState(false) // UI layout depends on the last 2 messages // (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change @@ -75,12 +74,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie vscode.postMessage({ type: "playSound", audioType }) } - function playSoundOnMessage(audioType: AudioType) { - if (hasStarted && !isStreaming) { - playSound(audioType) - } - } - useDeepCompareEffect(() => { // if last message is an ask, show user ask UI // if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost. @@ -91,7 +84,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const isPartial = lastMessage.partial === true switch (lastMessage.ask) { case "api_req_failed": - playSoundOnMessage("progress_loop") + playSound("progress_loop") setTextAreaDisabled(true) setClineAsk("api_req_failed") setEnableButtons(true) @@ -99,7 +92,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Start New Task") break case "mistake_limit_reached": - playSoundOnMessage("progress_loop") + playSound("progress_loop") setTextAreaDisabled(false) setClineAsk("mistake_limit_reached") setEnableButtons(true) @@ -107,7 +100,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Start New Task") break case "followup": - playSoundOnMessage("notification") + playSound("notification") setTextAreaDisabled(isPartial) setClineAsk("followup") setEnableButtons(isPartial) @@ -115,7 +108,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie // setSecondaryButtonText(undefined) break case "tool": - playSoundOnMessage("notification") + if (!isAutoApproved(lastMessage)) { + playSound("notification") + } setTextAreaDisabled(isPartial) setClineAsk("tool") setEnableButtons(!isPartial) @@ -134,7 +129,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } break case "browser_action_launch": - playSoundOnMessage("notification") + if (!isAutoApproved(lastMessage)) { + playSound("notification") + } setTextAreaDisabled(isPartial) setClineAsk("browser_action_launch") setEnableButtons(!isPartial) @@ -142,7 +139,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Reject") break case "command": - playSoundOnMessage("notification") + if (!isAutoApproved(lastMessage)) { + playSound("notification") + } setTextAreaDisabled(isPartial) setClineAsk("command") setEnableButtons(!isPartial) @@ -150,7 +149,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Reject") break case "command_output": - playSoundOnMessage("notification") setTextAreaDisabled(false) setClineAsk("command_output") setEnableButtons(true) @@ -166,7 +164,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie break case "completion_result": // extension waiting for feedback. but we can just present a new task button - playSoundOnMessage("celebration") + playSound("celebration") setTextAreaDisabled(isPartial) setClineAsk("completion_result") setEnableButtons(!isPartial) @@ -174,7 +172,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText(undefined) break case "resume_task": - playSoundOnMessage("notification") setTextAreaDisabled(false) setClineAsk("resume_task") setEnableButtons(true) @@ -183,7 +180,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setDidClickCancel(false) // special case where we reset the cancel button state break case "resume_completed_task": - playSoundOnMessage("celebration") + playSound("celebration") setTextAreaDisabled(false) setClineAsk("resume_completed_task") setEnableButtons(true) @@ -482,30 +479,86 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie return true }) }, [modifiedMessages]) - useEffect(() => { - if (isStreaming) { - // Set to true once any request has started - setHasStarted(true) + + const isReadOnlyToolAction = (message: ClineMessage | undefined) => { + if (message?.type === "ask" && message.text) { + const tool = JSON.parse(message.text) + return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool) } + return false + } + + const isWriteToolAction = (message: ClineMessage | undefined) => { + if (message?.type === "ask" && message.text) { + const tool = JSON.parse(message.text) + return ["editedExistingFile", "appliedDiff", "newFileCreated"].includes(tool.tool) + } + return false + } + + const isMcpToolAlwaysAllowed = (message: ClineMessage | undefined) => { + if (message?.type === "ask" && message.ask === "use_mcp_server" && message.text) { + const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string } + if (mcpServerUse.type === "use_mcp_tool") { + const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName) + const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName) + return tool?.alwaysAllow || false + } + } + return false + } + + const isAllowedCommand = (message: ClineMessage | undefined) => { + if (message?.type === "ask" && message.text) { + const command = message.text + + // Split command by chaining operators + const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim()) + + // Check if all individual commands are allowed + return commands.every((cmd) => { + const trimmedCommand = cmd.toLowerCase() + return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase())) + }) + } + return false + } + + const isAutoApproved = (message: ClineMessage | undefined) => { + if (!message || message.type !== "ask") return false + + return ( + (alwaysAllowBrowser && message.ask === "browser_action_launch") || + (alwaysAllowReadOnly && message.ask === "tool" && isReadOnlyToolAction(message)) || + (alwaysAllowWrite && message.ask === "tool" && isWriteToolAction(message)) || + (alwaysAllowExecute && message.ask === "command" && isAllowedCommand(message)) || + (alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message)) + ) + } + + useEffect(() => { // Only execute when isStreaming changes from true to false if (wasStreaming && !isStreaming && lastMessage) { // Play appropriate sound based on lastMessage content if (lastMessage.type === "ask") { - switch (lastMessage.ask) { - case "api_req_failed": - case "mistake_limit_reached": - playSound("progress_loop") - break - case "tool": - case "followup": - case "browser_action_launch": - case "resume_task": - playSound("notification") - break - case "completion_result": - case "resume_completed_task": - playSound("celebration") - break + // Don't play sounds for auto-approved actions + if (!isAutoApproved(lastMessage)) { + switch (lastMessage.ask) { + case "api_req_failed": + case "mistake_limit_reached": + playSound("progress_loop") + break + case "tool": + case "followup": + case "browser_action_launch": + case "resume_task": + playSound("notification") + break + case "completion_result": + case "resume_completed_task": + playSound("celebration") + break + } } } } @@ -750,61 +803,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie // Only proceed if we have an ask and buttons are enabled if (!clineAsk || !enableButtons) return - const isReadOnlyToolAction = () => { - const lastMessage = messages.at(-1) - if (lastMessage?.type === "ask" && lastMessage.text) { - const tool = JSON.parse(lastMessage.text) - return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool) - } - return false - } - - const isWriteToolAction = () => { - const lastMessage = messages.at(-1) - if (lastMessage?.type === "ask" && lastMessage.text) { - const tool = JSON.parse(lastMessage.text) - return ["editedExistingFile", "appliedDiff", "newFileCreated"].includes(tool.tool) - } - return false - } - - const isMcpToolAlwaysAllowed = () => { - const lastMessage = messages.at(-1) - if (lastMessage?.type === "ask" && lastMessage.ask === "use_mcp_server" && lastMessage.text) { - const mcpServerUse = JSON.parse(lastMessage.text) as { type: string; serverName: string; toolName: string } - if (mcpServerUse.type === "use_mcp_tool") { - const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName) - const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName) - return tool?.alwaysAllow || false - } - } - return false - } - - const isAllowedCommand = () => { - const lastMessage = messages.at(-1) - if (lastMessage?.type === "ask" && lastMessage.text) { - const command = lastMessage.text - - // Split command by chaining operators - const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim()) - - // Check if all individual commands are allowed - return commands.every((cmd) => { - const trimmedCommand = cmd.toLowerCase() - return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase())) - }) - } - return false - } - - if ( - (alwaysAllowBrowser && clineAsk === "browser_action_launch") || - (alwaysAllowReadOnly && clineAsk === "tool" && isReadOnlyToolAction()) || - (alwaysAllowWrite && clineAsk === "tool" && isWriteToolAction()) || - (alwaysAllowExecute && clineAsk === "command" && isAllowedCommand()) || - (alwaysAllowMcp && clineAsk === "use_mcp_server" && isMcpToolAlwaysAllowed()) - ) { + if (isAutoApproved(lastMessage)) { handlePrimaryButtonClick() } }, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers]) From 1adc36a2928095371ee03859644aa4018574e8ae Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Sun, 15 Dec 2024 23:22:44 -0800 Subject: [PATCH 03/12] add volume setting --- package-lock.json | 6 +++ package.json | 1 + src/core/webview/ClineProvider.ts | 14 +++++- src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 2 + src/utils/sound.ts | 17 +++++-- .../src/components/settings/SettingsView.tsx | 50 +++++++++++++++---- .../src/context/ExtensionStateContext.tsx | 4 ++ 8 files changed, 78 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 744ce85..43781fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", + "sound-play": "^1.1.0", "strip-ansi": "^7.1.0", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", @@ -14001,6 +14002,11 @@ "node": ">= 14" } }, + "node_modules/sound-play": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/sound-play/-/sound-play-1.1.0.tgz", + "integrity": "sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 7d9d6d3..ab18a7c 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,7 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", + "sound-play": "^1.1.0", "strip-ansi": "^7.1.0", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 15aa32a..1ce56ae 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -22,7 +22,7 @@ import { Cline } from "../Cline" import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" -import { playSound, setSoundEnabled } from "../../utils/sound" +import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -66,6 +66,7 @@ type GlobalStateKey = | "openRouterUseMiddleOutTransform" | "allowedCommands" | "soundEnabled" + | "soundVolume" | "diffEnabled" | "alwaysAllowMcp" @@ -597,6 +598,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { setSoundEnabled(soundEnabled) // Add this line to update the sound utility await this.postStateToWebview() break + case "soundVolume": + const soundVolume = message.value ?? 0.5 + await this.updateGlobalState("soundVolume", soundVolume) + setSoundVolume(soundVolume) + await this.postStateToWebview() + break case "diffEnabled": const diffEnabled = message.bool ?? true await this.updateGlobalState("diffEnabled", diffEnabled) @@ -929,6 +936,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundEnabled, diffEnabled, taskHistory, + soundVolume, } = await this.getState() const allowedCommands = vscode.workspace @@ -953,6 +961,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled: diffEnabled ?? false, shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands, + soundVolume: soundVolume ?? 0.5, } } @@ -1045,6 +1054,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { allowedCommands, soundEnabled, diffEnabled, + soundVolume, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1082,6 +1092,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("allowedCommands") as Promise, this.getGlobalState("soundEnabled") as Promise, this.getGlobalState("diffEnabled") as Promise, + this.getGlobalState("soundVolume") as Promise, ]) let apiProvider: ApiProvider @@ -1137,6 +1148,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { allowedCommands, soundEnabled, diffEnabled, + soundVolume, } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 608b5e5..07a3dde 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -51,6 +51,7 @@ export interface ExtensionState { uriScheme?: string allowedCommands?: string[] soundEnabled?: boolean + soundVolume?: number diffEnabled?: boolean } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 31802b9..2864a94 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -32,6 +32,7 @@ export interface WebviewMessage { | "alwaysAllowMcp" | "playSound" | "soundEnabled" + | "soundVolume" | "diffEnabled" | "openMcpSettings" | "restartMcpServer" @@ -43,6 +44,7 @@ export interface WebviewMessage { apiConfiguration?: ApiConfiguration images?: string[] bool?: boolean + value?: number commands?: string[] audioType?: AudioType // For toggleToolAutoApprove diff --git a/src/utils/sound.ts b/src/utils/sound.ts index 9255db4..a7f0d73 100644 --- a/src/utils/sound.ts +++ b/src/utils/sound.ts @@ -21,6 +21,7 @@ export const isWAV = (filepath: string): boolean => { } let isSoundEnabled = false +let volume = .5 /** * Set sound configuration @@ -30,6 +31,14 @@ export const setSoundEnabled = (enabled: boolean): void => { isSoundEnabled = enabled } +/** + * Set sound volume + * @param volume number + */ +export const setSoundVolume = (newVolume: number): void => { + volume = newVolume +} + /** * Play a sound file * @param filepath string @@ -54,11 +63,9 @@ export const playSound = (filepath: string): void => { return // Skip playback within minimum interval to prevent continuous playback } - const player = require("play-sound")() - player.play(filepath, function (err: any) { - if (err) { - throw new Error("Failed to play sound effect") - } + const sound = require("sound-play") + sound.play(filepath, volume).catch(() => { + throw new Error("Failed to play sound effect") }) lastPlayedTime = currentTime diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index deab3d6..f4e2da9 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -29,6 +29,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysAllowMcp, soundEnabled, setSoundEnabled, + soundVolume, + setSoundVolume, diffEnabled, setDiffEnabled, openRouterModels, @@ -55,6 +57,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp }) vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] }) vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }) + vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) onDone() } @@ -306,17 +309,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

Experimental Features

- setSoundEnabled(e.target.checked)}> - Enable sound effects - -

- When enabled, Cline will play sound effects for notifications and events. -

+
+ setSoundEnabled(e.target.checked)}> + Enable sound effects + +

+ When enabled, Cline will play sound effects for notifications and events. +

+
+ {soundEnabled && ( +
+
+ Volume + setSoundVolume(parseFloat(e.target.value))} + style={{ + flexGrow: 1, + accentColor: 'var(--vscode-button-background)', + height: '2px' + }} + /> + + {Math.round((soundVolume ?? 0.5) * 100)}% + +
+
+ )}
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index f9690b6..c8307ff 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -29,6 +29,7 @@ export interface ExtensionStateContextType extends ExtensionState { setShowAnnouncement: (value: boolean) => void setAllowedCommands: (value: string[]) => void setSoundEnabled: (value: boolean) => void + setSoundVolume: (value: number) => void setDiffEnabled: (value: boolean) => void } @@ -42,6 +43,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode shouldShowAnnouncement: false, allowedCommands: [], soundEnabled: false, + soundVolume: 0.5, diffEnabled: false, }) const [didHydrateState, setDidHydrateState] = useState(false) @@ -129,6 +131,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterModels, mcpServers, filePaths, + soundVolume: state.soundVolume, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })), setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })), @@ -139,6 +142,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })), setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })), setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })), + setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })), setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), } From 5e771cf783cfd7198a975035c4d64569ead25de3 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Sun, 15 Dec 2024 23:44:50 -0800 Subject: [PATCH 04/12] add volume slider tests --- .../settings/__tests__/SettingsView.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index 50fd597..42d7021 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -104,6 +104,9 @@ describe('SettingsView - Sound Settings', () => { name: /Enable sound effects/i }) expect(soundCheckbox).not.toBeChecked() + + // Volume slider should not be visible when sound is disabled + expect(screen.queryByRole('slider')).not.toBeInTheDocument() }) it('toggles sound setting and sends message to VSCode', () => { @@ -128,6 +131,50 @@ describe('SettingsView - Sound Settings', () => { }) ) }) + + it('shows volume slider when sound is enabled', () => { + renderSettingsView() + + // Enable sound + const soundCheckbox = screen.getByRole('checkbox', { + name: /Enable sound effects/i + }) + fireEvent.click(soundCheckbox) + + // Volume slider should be visible + const volumeSlider = screen.getByRole('slider') + expect(volumeSlider).toBeInTheDocument() + expect(volumeSlider).toHaveValue('0.5') // Default value + }) + + it('updates volume and sends message to VSCode when slider changes', () => { + renderSettingsView() + + // Enable sound + const soundCheckbox = screen.getByRole('checkbox', { + name: /Enable sound effects/i + }) + fireEvent.click(soundCheckbox) + + // Change volume + const volumeSlider = screen.getByRole('slider') + fireEvent.change(volumeSlider, { target: { value: '0.75' } }) + + // Verify volume display updates + expect(screen.getByText('75%')).toBeInTheDocument() + + // Click Done to save settings + const doneButton = screen.getByText('Done') + fireEvent.click(doneButton) + + // Verify message sent to VSCode + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'soundVolume', + value: 0.75 + }) + ) + }) }) describe('SettingsView - Allowed Commands', () => { From 95222a9a5dd4cb96ed8edc61a63e09d1f68c1566 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Sun, 15 Dec 2024 23:47:10 -0800 Subject: [PATCH 05/12] add sounds to mcp server approval --- webview-ui/src/components/chat/ChatView.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 777425e..a959cdb 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -156,6 +156,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText(undefined) break case "use_mcp_server": + if (!isAutoApproved(lastMessage)) { + playSound("notification") + } setTextAreaDisabled(isPartial) setClineAsk("use_mcp_server") setEnableButtons(!isPartial) @@ -552,6 +555,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "followup": case "browser_action_launch": case "resume_task": + case "use_mcp_server": playSound("notification") break case "completion_result": From efdc3a86392c464fbb5f18bb5e638efaea58c031 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 00:26:00 -0800 Subject: [PATCH 06/12] ChatView tests --- .../chat/__tests__/ChatView.test.tsx | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx index ad7565a..9e2d2c4 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx @@ -547,3 +547,247 @@ describe('ChatView - Auto Approval Tests', () => { }) }) }) + +describe('ChatView - Sound Playing Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('does not play sound for auto-approved browser actions', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task and streaming + mockPostMessage({ + alwaysAllowBrowser: true, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'say', + say: 'api_req_started', + ts: Date.now() - 1000, + text: JSON.stringify({}), + partial: true + } + ] + }) + + // Then send the browser action ask message (streaming finished) + mockPostMessage({ + alwaysAllowBrowser: true, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'browser_action_launch', + ts: Date.now(), + text: JSON.stringify({ action: 'launch', url: 'http://example.com' }), + partial: false + } + ] + }) + + // Verify no sound was played + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: 'playSound', + audioType: expect.any(String) + }) + }) + + it('plays notification sound for non-auto-approved browser actions', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task and streaming + mockPostMessage({ + alwaysAllowBrowser: false, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'say', + say: 'api_req_started', + ts: Date.now() - 1000, + text: JSON.stringify({}), + partial: true + } + ] + }) + + // Then send the browser action ask message (streaming finished) + mockPostMessage({ + alwaysAllowBrowser: false, + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'browser_action_launch', + ts: Date.now(), + text: JSON.stringify({ action: 'launch', url: 'http://example.com' }), + partial: false + } + ] + }) + + // Verify notification sound was played + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'playSound', + audioType: 'notification' + }) + }) + }) + + it('plays celebration sound for completion results', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task and streaming + mockPostMessage({ + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'say', + say: 'api_req_started', + ts: Date.now() - 1000, + text: JSON.stringify({}), + partial: true + } + ] + }) + + // Then send the completion result message (streaming finished) + mockPostMessage({ + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'completion_result', + ts: Date.now(), + text: 'Task completed successfully', + partial: false + } + ] + }) + + // Verify celebration sound was played + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'playSound', + audioType: 'celebration' + }) + }) + }) + + it('plays progress_loop sound for api failures', async () => { + render( + + {}} + showHistoryView={() => {}} + /> + + ) + + // First hydrate state with initial task and streaming + mockPostMessage({ + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'say', + say: 'api_req_started', + ts: Date.now() - 1000, + text: JSON.stringify({}), + partial: true + } + ] + }) + + // Then send the api failure message (streaming finished) + mockPostMessage({ + clineMessages: [ + { + type: 'say', + say: 'task', + ts: Date.now() - 2000, + text: 'Initial task' + }, + { + type: 'ask', + ask: 'api_req_failed', + ts: Date.now(), + text: 'API request failed', + partial: false + } + ] + }) + + // Verify progress_loop sound was played + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'playSound', + audioType: 'progress_loop' + }) + }) + }) +}) From c66fe250601a2d38282334b1d37958502bdd2329 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 09:30:20 -0800 Subject: [PATCH 07/12] ignore command and mcp asks with no text --- webview-ui/src/components/chat/ChatView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a959cdb..604285d 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -139,7 +139,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Reject") break case "command": - if (!isAutoApproved(lastMessage)) { + if (lastMessage.text && !isAutoApproved(lastMessage)) { playSound("notification") } setTextAreaDisabled(isPartial) @@ -156,7 +156,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText(undefined) break case "use_mcp_server": - if (!isAutoApproved(lastMessage)) { + if (lastMessage.text && !isAutoApproved(lastMessage)) { playSound("notification") } setTextAreaDisabled(isPartial) From 05d6c295be548bbee34b1d5cd0f2b81acfa6031a Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 10:41:13 -0800 Subject: [PATCH 08/12] fix auto-approved and duplicate sounds --- webview-ui/src/components/chat/ChatView.tsx | 31 +++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 604285d..6972430 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -100,7 +100,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Start New Task") break case "followup": - playSound("notification") setTextAreaDisabled(isPartial) setClineAsk("followup") setEnableButtons(isPartial) @@ -139,7 +138,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText("Reject") break case "command": - if (lastMessage.text && !isAutoApproved(lastMessage)) { + if (!isAutoApproved(lastMessage)) { playSound("notification") } setTextAreaDisabled(isPartial) @@ -156,7 +155,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText(undefined) break case "use_mcp_server": - if (lastMessage.text && !isAutoApproved(lastMessage)) { + if (!isAutoApproved(lastMessage)) { playSound("notification") } setTextAreaDisabled(isPartial) @@ -484,7 +483,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie }, [modifiedMessages]) const isReadOnlyToolAction = (message: ClineMessage | undefined) => { - if (message?.type === "ask" && message.text) { + if (message?.type === "ask") { + if (!message.text) { + return true + } const tool = JSON.parse(message.text) return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool) } @@ -492,7 +494,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } const isWriteToolAction = (message: ClineMessage | undefined) => { - if (message?.type === "ask" && message.text) { + if (message?.type === "ask") { + if (!message.text) { + return true + } const tool = JSON.parse(message.text) return ["editedExistingFile", "appliedDiff", "newFileCreated"].includes(tool.tool) } @@ -500,7 +505,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } const isMcpToolAlwaysAllowed = (message: ClineMessage | undefined) => { - if (message?.type === "ask" && message.ask === "use_mcp_server" && message.text) { + if (message?.type === "ask" && message.ask === "use_mcp_server") { + if (!message.text) { + return true + } const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string } if (mcpServerUse.type === "use_mcp_tool") { const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName) @@ -512,8 +520,11 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } const isAllowedCommand = (message: ClineMessage | undefined) => { - if (message?.type === "ask" && message.text) { + if (message?.type === "ask") { const command = message.text + if (!command) { + return true + } // Split command by chaining operators const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim()) @@ -551,8 +562,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "mistake_limit_reached": playSound("progress_loop") break - case "tool": case "followup": + if (!lastMessage.partial) { + playSound("notification") + } + break + case "tool": case "browser_action_launch": case "resume_task": case "use_mcp_server": From d4ceee396d12f070b2446d75546629177568a58d Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 10:46:11 -0800 Subject: [PATCH 09/12] uninstall play-sound --- package-lock.json | 18 +----------------- package.json | 1 - 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9790f1d..ae7aa41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "os-name": "^6.0.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", - "play-sound": "^1.1.6", "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", @@ -8851,14 +8850,6 @@ "node": ">=8" } }, - "node_modules/find-exec": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/find-exec/-/find-exec-1.0.3.tgz", - "integrity": "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug==", - "dependencies": { - "shell-quote": "^1.8.1" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -13103,14 +13094,6 @@ "node": ">=8" } }, - "node_modules/play-sound": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/play-sound/-/play-sound-1.1.6.tgz", - "integrity": "sha512-09eO4QiXNFXJffJaOW5P6x6F5RLihpLUkXttvUZeWml0fU6x6Zp7AjG9zaeMpgH2ZNvq4GR1ytB22ddYcqJIZA==", - "dependencies": { - "find-exec": "1.0.3" - } - }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -13874,6 +13857,7 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, "engines": { "node": ">= 0.4" }, diff --git a/package.json b/package.json index 2f74913..98dc4bd 100644 --- a/package.json +++ b/package.json @@ -216,7 +216,6 @@ "os-name": "^6.0.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", - "play-sound": "^1.1.6", "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", From ac2babfd82d0a7b2778f54bf36abafe193484932 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 14:07:07 -0800 Subject: [PATCH 10/12] remove double ping for mcp approval --- webview-ui/src/components/chat/ChatView.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 6972430..696df80 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -155,9 +155,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setSecondaryButtonText(undefined) break case "use_mcp_server": - if (!isAutoApproved(lastMessage)) { - playSound("notification") - } setTextAreaDisabled(isPartial) setClineAsk("use_mcp_server") setEnableButtons(!isPartial) From 8f1fef249b321f4ab48bb8fc829854d82da72f18 Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 14:17:38 -0800 Subject: [PATCH 11/12] changeset --- .changeset/eighty-nails-peel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eighty-nails-peel.md diff --git a/.changeset/eighty-nails-peel.md b/.changeset/eighty-nails-peel.md new file mode 100644 index 0000000..bfcedc8 --- /dev/null +++ b/.changeset/eighty-nails-peel.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add volume slider and change sound effect triggers From ca4806db224ba1b702deced67ee01139cbb8d18e Mon Sep 17 00:00:00 2001 From: Justin Quan Date: Mon, 16 Dec 2024 14:23:13 -0800 Subject: [PATCH 12/12] add details to changeset --- .changeset/eighty-nails-peel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/eighty-nails-peel.md b/.changeset/eighty-nails-peel.md index bfcedc8..1810d53 100644 --- a/.changeset/eighty-nails-peel.md +++ b/.changeset/eighty-nails-peel.md @@ -2,4 +2,4 @@ "roo-cline": patch --- -Add volume slider and change sound effect triggers +Add volume slider in settings and change sound effects to only trigger when user intervention is required, an error occurs, or a task is completed.