From e18ce6fbee30966f7a51efd3ff55442948fb8477 Mon Sep 17 00:00:00 2001 From: Jay <53023+jayair@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:07:20 +0000 Subject: [PATCH] fix(app): mute comment-only prompt errors --- .../components/prompt-input/submit.test.ts | 75 ++++++++++++++++++- .../app/src/components/prompt-input/submit.ts | 10 +++ .../context/notification-error-sound.test.ts | 22 ++++++ .../src/context/notification-error-sound.ts | 22 ++++++ packages/app/src/context/notification.tsx | 18 ++++- packages/app/src/pages/session.tsx | 2 + 6 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/context/notification-error-sound.test.ts create mode 100644 packages/app/src/context/notification-error-sound.ts diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index f563a50982..8b32939eee 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -23,6 +23,7 @@ const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] +const mutedErrorSounds: string[] = [] let params: { id?: string } = {} let search: { draftId?: string } = {} @@ -32,6 +33,8 @@ let permissionServer = "server-a" let createSessionGate: Promise | undefined const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +let currentPrompt = promptValue +let contextItems: PromptStore["context"]["items"] = [] const [promptStore, setPromptStore] = createStore({ prompt: promptValue, cursor: 0, @@ -40,7 +43,7 @@ const [promptStore, setPromptStore] = createStore({ const prompt = { store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore], ready: Object.assign(() => true, { promise: Promise.resolve(true) }), - current: () => promptValue, + current: () => currentPrompt, cursor: () => 0, dirty: () => true, model: { @@ -55,7 +58,7 @@ const prompt = { removeComment: () => undefined, updateComment: () => undefined, replaceComments: () => undefined, - items: () => [], + items: () => contextItems, }, capture: () => prompt, } @@ -141,6 +144,17 @@ beforeAll(async () => { return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } }) + mock.module("@/context/notification", () => ({ + useNotification: () => ({ + error: { + muteNextSound(sessionID: string) { + mutedErrorSounds.push(sessionID) + return () => undefined + }, + }, + }), + })) + mock.module("@/context/server", () => ({ useServer: () => ({ key: "server-key" }), })) @@ -256,6 +270,7 @@ beforeEach(() => { optimisticSeeded.length = 0 promoted.length = 0 promotedDrafts.length = 0 + mutedErrorSounds.length = 0 params = {} search = {} sentShell.length = 0 @@ -264,10 +279,66 @@ beforeEach(() => { variant = undefined permissionServer = "server-a" createSessionGate = undefined + currentPrompt = promptValue + contextItems = [] for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) describe("prompt submit worktree selection", () => { + const commentSubmit = (promptLength: number) => + createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 1, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: () => promptLength, + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + test("mutes the error sound for a comment-only prompt", async () => { + params = { id: "session-1" } + currentPrompt = [{ type: "text", content: "", start: 0, end: 0 }] + contextItems = [ + { + key: "comment-1", + type: "file", + path: "src/example.ts", + comment: "Please rename this", + commentID: "comment-1", + commentOrigin: "review", + }, + ] + await commentSubmit(0).handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(mutedErrorSounds).toEqual(["session-1"]) + }) + + test("keeps error sounds enabled when the prompt includes text", async () => { + params = { id: "session-1" } + contextItems = [ + { + key: "comment-1", + type: "file", + path: "src/example.ts", + comment: "Please rename this", + commentID: "comment-1", + commentOrigin: "review", + }, + ] + await commentSubmit(2).handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(mutedErrorSounds).toEqual([]) + }) + test("reads the latest worktree accessor value per submit", async () => { const submit = createPromptSubmit({ prompt, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 203722fc6c..0122c0c3df 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -10,6 +10,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal, type ModelSelection } from "@/context/local" import { usePermission } from "@/context/permission" +import { useNotification } from "@/context/notification" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSync, type DirectorySync } from "@/context/sync" @@ -46,6 +47,7 @@ type FollowupSendInput = { messageID?: string optimisticBusy?: boolean before?: () => Promise | boolean + muteErrorSound?: (sessionID: string) => VoidFunction } const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") @@ -104,6 +106,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } const messageID = input.messageID ?? Identifier.ascending("message") + const cancelErrorSoundMute = + text.trim().length === 0 && images.length === 0 && input.draft.context.some((item) => !!item.comment?.trim()) + ? input.muteErrorSound?.(input.draft.sessionID) + : undefined const { requestParts, optimisticParts } = buildRequestParts({ prompt: input.draft.prompt, context: input.draft.context, @@ -145,6 +151,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { try { if (!(await wait())) { + cancelErrorSoundMute?.() batch(() => { setIdle() remove() @@ -162,6 +169,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { }) return true } catch (err) { + cancelErrorSoundMute?.() batch(() => { setIdle() remove() @@ -201,6 +209,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const serverSync = useServerSync() const local = useLocal() const permission = usePermission() + const notification = useNotification() const prompt = input.prompt const layout = useLayout() const language = useLanguage() @@ -580,6 +589,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { messageID, optimisticBusy: sessionDirectory === projectDirectory, before: waitForWorktree, + muteErrorSound: notification.error.muteNextSound, }).catch((err) => { pending.delete(pendingKey(session.id)) if (sessionDirectory === projectDirectory) { diff --git a/packages/app/src/context/notification-error-sound.test.ts b/packages/app/src/context/notification-error-sound.test.ts new file mode 100644 index 0000000000..446084f80d --- /dev/null +++ b/packages/app/src/context/notification-error-sound.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { createNotificationErrorSound } from "./notification-error-sound" + +describe("notification error sound", () => { + test("mutes one session error and keeps other errors audible", () => { + const sound = createNotificationErrorSound() + sound.muteNext("session-comment-only") + + expect(sound.shouldPlay("session-other")).toBe(true) + expect(sound.shouldPlay()).toBe(true) + expect(sound.shouldPlay("session-comment-only")).toBe(false) + expect(sound.shouldPlay("session-comment-only")).toBe(true) + }) + + test("restores the sound when the session settles without an error", () => { + const sound = createNotificationErrorSound() + sound.muteNext("session-comment-only") + sound.settle("session-comment-only") + + expect(sound.shouldPlay("session-comment-only")).toBe(true) + }) +}) diff --git a/packages/app/src/context/notification-error-sound.ts b/packages/app/src/context/notification-error-sound.ts new file mode 100644 index 0000000000..ce79e751c0 --- /dev/null +++ b/packages/app/src/context/notification-error-sound.ts @@ -0,0 +1,22 @@ +export function createNotificationErrorSound() { + const muted = new Set() + + return { + muteNext(sessionID: string) { + muted.add(sessionID) + return () => { + muted.delete(sessionID) + } + }, + shouldPlay(sessionID?: string) { + if (!sessionID) return true + return !muted.delete(sessionID) + }, + settle(sessionID?: string) { + if (sessionID) muted.delete(sessionID) + }, + dispose() { + muted.clear() + }, + } +} diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index e12f8f1250..29cca2d382 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,6 +12,7 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" +import { createNotificationErrorSound } from "./notification-error-sound" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { type DraftTab, useTabs } from "./tabs" @@ -181,6 +182,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi return { ready: () => selected().ready(), ensureServerState: ensure, + error: { + muteNextSound: (session: string) => selected().error.muteNextSound(session), + }, session: { all: (session: string) => selected().session.all(session), unseen: (session: string) => selected().session.unseen(session), @@ -231,6 +235,7 @@ function createServerNotificationState(input: { const [index, setIndex] = createStore(buildNotificationIndex(store.list)) const meta = { pruned: false, disposed: false } + const errorSound = createNotificationErrorSound() const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { setIndex(scope, "unseen", key, unseen) @@ -327,6 +332,7 @@ function createServerNotificationState(input: { const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { const sessionID = event.properties.sessionID + errorSound.settle(sessionID) void lookup(directory, sessionID).then((session) => { if (meta.disposed) return if (!session) return @@ -355,13 +361,14 @@ function createServerNotificationState(input: { directory: string, event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, time: number, + sound: boolean, ) => { const sessionID = event.properties.sessionID void lookup(directory, sessionID).then((session) => { if (meta.disposed) return if (session?.parentID) return - if (settings.sounds.errorsEnabled()) { + if (sound && settings.sounds.errorsEnabled()) { void playSoundById(settings.sounds.errors()) } @@ -394,15 +401,22 @@ function createServerNotificationState(input: { handleSessionIdle(directory, event, time) return } - handleSessionError(directory, event, time) + const sessionID = event.properties.sessionID + handleSessionError(directory, event, time, errorSound.shouldPlay(sessionID)) }) onCleanup(() => { meta.disposed = true unsub() + errorSound.dispose() }) return { ready, + error: { + muteNextSound(session: string) { + return errorSound.muteNext(session) + }, + }, session: { all(session: string) { return index.session.all[session] ?? empty diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2327cbf735..bc06ba96c1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -364,6 +364,7 @@ export default function Page() { const platform = usePlatform() const prompt = usePrompt() const comments = useComments() + const notification = useNotification() const command = useCommand() const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() @@ -1761,6 +1762,7 @@ export default function Page() { serverSync: serverSync(), draft: item, optimisticBusy: item.sessionDirectory === sdk().directory, + muteErrorSound: notification.error.muteNextSound, }).catch((err) => { setFollowup("failed", input.sessionID, input.id) fail(err)