fix(app): mute comment-only prompt errors
This commit is contained in:
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user