Compare commits

...
6 changed files with 156 additions and 29 deletions
+13 -13
View File
@@ -105,14 +105,16 @@ export function AppInterface(props: {
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
@@ -123,11 +125,9 @@ export function AppInterface(props: {
servers={props.servers}
>
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</SettingsProvider>
</ServersProvider>
)
+5 -2
View File
@@ -10,6 +10,7 @@ import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { Persist, persisted } from "@/runtime/persistence/storage"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
@@ -26,6 +27,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
@@ -50,7 +52,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
}, owner)
serverCtxs.set(key, serverCtx)
return serverCtx
@@ -131,6 +133,7 @@ function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
@@ -145,7 +148,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
const permission = createServerPermissionState({ sdk, sync, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -0,0 +1,93 @@
import { onCleanup } from "solid-js"
const FOCUS_LOCK = "opencode:notification-focus"
const MAX_CLAIMED = 500
export function createNotificationCoordinator() {
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
const claimed = new Set<string>()
const focus = { pending: false, release: undefined as (() => void) | undefined }
const updateFocus = () => {
if (typeof document === "undefined" || !document.hasFocus()) {
focus.release?.()
return
}
if (!locks || focus.pending || focus.release) return
focus.pending = true
void locks
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
focus.pending = false
if (!document.hasFocus()) return
await new Promise<void>((resolve) => {
focus.release = resolve
})
focus.release = undefined
})
.catch(() => {
focus.pending = false
})
}
if (typeof window !== "undefined") {
window.addEventListener("focus", updateFocus)
window.addEventListener("blur", updateFocus)
document.addEventListener("visibilitychange", updateFocus)
updateFocus()
onCleanup(() => {
window.removeEventListener("focus", updateFocus)
window.removeEventListener("blur", updateFocus)
document.removeEventListener("visibilitychange", updateFocus)
focus.release?.()
})
}
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
const key = `${kind}:${eventID}`
const execute = async () => {
if (!claim(kind, key, claimed)) return
await run()
}
if (!locks) return execute()
await locks.request(`opencode:notification:${key}`, execute)
}
return {
sound(eventID: string, run: () => Promise<unknown> | void) {
return once("sound", eventID, run)
},
system(eventID: string, run: () => Promise<unknown> | void) {
return once("system", eventID, async () => {
if (typeof document !== "undefined" && document.hasFocus()) return
if (!locks) return run()
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
if (!lock) return
await run()
})
})
},
}
}
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const storageKey = `opencode:notification-${kind}`
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
@@ -10,9 +10,10 @@ import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
@@ -108,10 +109,16 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
export function createServerNotificationState(input: {
sdk: ServerSDK
data: Data
key: ServerConnection.Key
coordinator: ReturnType<typeof createNotificationCoordinator>
}) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const tabs = useTabs()
const empty: Notification[] = []
const [store, setStore, _, ready] = persisted(
@@ -215,14 +222,17 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (sessionID: string, time: number) => {
const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.agentEnabled()
) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
}
append({
@@ -235,8 +245,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const href = sessionHref(input.key, sessionID)
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
navigate(href),
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
navigate(href),
),
)
}
})
@@ -245,14 +257,18 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
sessionID: string,
error: ErrorNotification["error"],
eventID: string,
time: number,
) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.errorsEnabled()
) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
}
append({
@@ -268,7 +284,9 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionHref(input.key, sessionID)
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () => navigate(href)),
)
}
})
}
@@ -278,10 +296,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(event.data.sessionID, event.data.error, time)
handleSessionError(event.data.sessionID, event.data.error, event.id, time)
return
}
handleSessionIdle(event.data.sessionID, time)
handleSessionIdle(event.data.sessionID, event.id, time)
})
onCleanup(() => {
meta.disposed = true
+10 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -47,6 +47,15 @@ test("session tab identity stays rooted while its href follows the child route",
expect(tabHref(child)).toContain("/session/child")
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
expect(sessionIDHasOpenTab(tabs, "other" as ServerConnection.Key, "root")).toBe(false)
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
+5 -1
View File
@@ -51,11 +51,15 @@ export const tabKey = (tab: Tab) =>
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
(tab.sessionId === session.id || tab.routeSessionId === session.id),
(tab.sessionId === sessionID || tab.routeSessionId === sessionID),
)
}