diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8adaa1f0fdf9..3bbc6c4ac207 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -28,7 +28,7 @@ import { useSettings, } from "@/context/settings" import { decode64 } from "@/utils/base64" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundPreview, SOUND_OPTIONS } from "@/utils/sound" import { ExternalLink } from "./external-link" import { SettingsList } from "./settings-list" @@ -72,7 +72,7 @@ const playDemoSound = (id: string | undefined) => { const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { + void playSoundPreview(id).then((cleanup) => { if (demoSoundState.run !== run) { cleanup?.() return diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts index ae77fa332f22..3cbd7631114f 100644 --- a/packages/app/src/components/settings-v2/general-controllers.ts +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -16,7 +16,7 @@ import { terminalInput, useSettings, } from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundPreview, SOUND_OPTIONS } from "@/utils/sound" import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" @@ -118,7 +118,7 @@ export type SoundSelectOption = (typeof soundOptions)[number] export function createSoundSettingsController() { const settings = useSettings() - const preview = createSoundPreviewController(playSoundById) + const preview = createSoundPreviewController(playSoundPreview) const channel = ( enabled: Accessor, current: Accessor, diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index a95706a3dbe5..f95a92b2fd26 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -145,6 +145,57 @@ describe("bootstrapDirectory", () => { expect(mcpReads.sort()).toEqual(["command", "resource", "status"]) }) + test("directory is marked complete without waiting for a slow MCP server", async () => { + const [store, setStore] = directoryState() + let mcpSettled = false + const mcpBlock = () => + new Promise<{ data: Record }>((resolve) => + setTimeout(() => { + mcpSettled = true + resolve({ data: {} }) + }, 400), + ) + + await bootstrapDirectory({ + directory: "/project", + scope: ServerScope.local, + mcp: true, + global: { + config: {} satisfies Config, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + project: [{ id: "project", worktree: "/project" } as Project], + provider, + }, + sdk: { + app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, + config: { get: async () => ({ data: {} }) }, + session: { status: async () => ({ data: {} }) }, + vcs: { get: async () => ({ data: undefined }) }, + command: { list: async () => ({ data: [] }) }, + permission: { list: async () => ({ data: [] }) }, + question: { list: async () => ({ data: [] }) }, + v2: { reference: { list: async () => ({ data: { data: [] } }) } }, + mcp: { status: async () => mcpBlock() }, + experimental: { resource: { list: async () => mcpBlock() } }, + provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, + } as unknown as OpencodeClient, + api, + store, + setStore, + vcsCache: { setStore() {} } as unknown as VcsCache, + loadSessions() {}, + translate: (key) => key, + queryClient: new QueryClient(), + protocol: Promise.resolve("v1"), + }) + + expect(store.status).toBe("partial") + await new Promise((resolve) => setTimeout(resolve, 80)) + // Bootstrap must finish while MCP is still pending. + expect(store.status).toBe("complete") + expect(mcpSettled).toBe(false) + }) + test("skips legacy config while refreshing a v2 directory", async () => { const [store, setStore] = directoryState() diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0f3e47381649..cf7566843a2d 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -514,16 +514,6 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && - (() => - input.queryClient.fetchQuery( - loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), - )), - input.mcp && - (() => - input.queryClient.fetchQuery( - loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), - )), () => input.queryClient .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) @@ -537,6 +527,17 @@ export async function bootstrapDirectory(input: { }), ].filter(Boolean) as (() => Promise)[] + // MCP servers can take up to their connect timeout to answer, so warm the + // MCP queries in the background instead of blocking directory readiness. + if (input.mcp) { + void input.queryClient + .fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol)) + .catch(() => {}) + void input.queryClient + .fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol)) + .catch(() => {}) + } + await waitForPaint() const slowErrs = errors(await runAll(slow)) if (slowErrs.length > 0) { diff --git a/packages/app/src/utils/sound.test.ts b/packages/app/src/utils/sound.test.ts new file mode 100644 index 000000000000..6ff419674c02 --- /dev/null +++ b/packages/app/src/utils/sound.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import { createSoundPlayer } from "./sound" + +const deferred = () => { + let resolve!: (value: string | undefined) => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +describe("createSoundPlayer", () => { + test("collapses a burst of plays into a single sound", async () => { + let now = 0 + const played: string[] = [] + const player = createSoundPlayer({ + load: async (id) => `src:${id}`, + play: (src) => { + played.push(src!) + return () => {} + }, + now: () => now, + cooldownMs: 2_000, + }) + + await Promise.all([player("nope-03"), player("nope-03"), player("nope-03"), player("nope-03")]) + + expect(played).toEqual(["src:nope-03"]) + }) + + test("plays again after the cooldown elapses", async () => { + let now = 0 + const played: string[] = [] + const player = createSoundPlayer({ + load: async (id) => `src:${id}`, + play: (src) => { + played.push(src!) + return () => {} + }, + now: () => now, + cooldownMs: 2_000, + }) + + await player("nope-03") + now = 1_999 + await player("nope-03") + now = 2_000 + await player("nope-03") + + expect(played).toEqual(["src:nope-03", "src:nope-03"]) + }) + + test("suppresses concurrent plays even when loading is slow", async () => { + const first = deferred() + const played: string[] = [] + const player = createSoundPlayer({ + load: () => first.promise, + play: (src) => { + played.push(src!) + return () => {} + }, + cooldownMs: 2_000, + }) + + const a = player("nope-03") + const b = player("nope-03") + first.resolve("src:nope-03") + await Promise.all([a, b]) + + expect(played).toEqual(["src:nope-03"]) + }) + + test("does not play unknown or undefined sounds", async () => { + const played: string[] = [] + const player = createSoundPlayer({ + load: async () => undefined, + play: (src) => { + played.push(src!) + return () => {} + }, + }) + + await player(undefined) + await player("missing") + + expect(played).toEqual([]) + }) + + test("does not block a later sound after a failed load", async () => { + let now = 0 + const played: string[] = [] + const player = createSoundPlayer({ + load: async (id) => (id === "bad" ? undefined : `src:${id}`), + play: (src) => { + played.push(src!) + return () => {} + }, + now: () => now, + cooldownMs: 2_000, + }) + + await player("bad") + now = 2_000 + await player("nope-03") + + expect(played).toEqual(["src:nope-03"]) + }) +}) diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 78e5a0c565e0..28cb99ee22b5 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -97,6 +97,40 @@ export function playSound(src: string | undefined) { } } -export function playSoundById(id: string | undefined) { - return soundSrc(id).then((src) => playSound(src)) +export const SOUND_COOLDOWN_MS = 2_000 + +export function createSoundPlayer(input: { + load: (id: string | undefined) => Promise + play: (src: string | undefined) => (() => void) | undefined + now?: () => number + cooldownMs?: number +}) { + const now = input.now ?? Date.now + const cooldownMs = input.cooldownMs ?? SOUND_COOLDOWN_MS + let lastPlayedAt = Number.NEGATIVE_INFINITY + let cleanup: (() => void) | undefined + let inflight: Promise<(() => void) | undefined> | undefined + + return (id: string | undefined): Promise<(() => void) | undefined> => { + if (inflight) return inflight + if (now() - lastPlayedAt < cooldownMs) return Promise.resolve(undefined) + inflight = input + .load(id) + .then((src) => { + if (!src) return undefined + lastPlayedAt = now() + cleanup?.() + cleanup = input.play(src) + return cleanup + }) + .finally(() => { + inflight = undefined + }) + return inflight + } } + +export const playSoundById = createSoundPlayer({ load: soundSrc, play: playSound }) + +// Settings previews play on explicit user action and must not be throttled. +export const playSoundPreview = createSoundPlayer({ load: soundSrc, play: playSound, cooldownMs: 0 })