Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<boolean>,
current: Accessor<string>,
Expand Down
51 changes: 51 additions & 0 deletions packages/app/src/context/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }>((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()

Expand Down
21 changes: 11 additions & 10 deletions packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -537,6 +527,17 @@ export async function bootstrapDirectory(input: {
}),
].filter(Boolean) as (() => Promise<any>)[]

// 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) {
Expand Down
106 changes: 106 additions & 0 deletions packages/app/src/utils/sound.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>((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"])
})
})
38 changes: 36 additions & 2 deletions packages/app/src/utils/sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>
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 })
Loading