fix(app): mute comment-only prompt errors

This commit is contained in:
Jay
2026-07-18 00:07:20 +00:00
committed by OpenCode Agent
parent 45cd8d7692
commit e18ce6fbee
6 changed files with 145 additions and 4 deletions
@@ -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<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
let currentPrompt = promptValue
let contextItems: PromptStore["context"]["items"] = []
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
@@ -40,7 +43,7 @@ const [promptStore, setPromptStore] = createStore<PromptStore>({
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,
@@ -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> | 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) {
@@ -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)
})
})
@@ -0,0 +1,22 @@
export function createNotificationErrorSound() {
const muted = new Set<string>()
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()
},
}
}
+16 -2
View File
@@ -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<NotificationIndex>(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
+2
View File
@@ -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)