opencode/packages/app/src/context/notification.tsx

219 lines
7.1 KiB
TypeScript
Raw Normal View History

2025-12-12 19:14:47 +08:00
import { createStore } from "solid-js/store"
2026-01-26 20:06:10 +08:00
import { createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
2025-12-12 19:14:47 +08:00
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
2025-12-16 00:13:29 +08:00
import { useGlobalSync } from "./global-sync"
2025-12-30 10:54:33 +08:00
import { usePlatform } from "@/context/platform"
2026-01-21 00:43:43 +08:00
import { useLanguage } from "@/context/language"
2026-01-07 06:03:39 +08:00
import { useSettings } from "@/context/settings"
2025-12-16 00:13:29 +08:00
import { Binary } from "@opencode-ai/util/binary"
2026-01-27 20:27:27 +08:00
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
2025-12-12 19:14:47 +08:00
import { EventSessionError } from "@opencode-ai/sdk/v2"
2026-01-07 11:38:08 +08:00
import { Persist, persisted } from "@/utils/persist"
2026-01-07 06:03:39 +08:00
import { playSound, soundSrc } from "@/utils/sound"
2026-02-06 19:51:01 +08:00
import { buildNotificationIndex } from "./notification-index"
2025-12-12 19:14:47 +08:00
type NotificationBase = {
directory?: string
session?: string
metadata?: unknown
2025-12-12 19:14:47 +08:00
time: number
viewed: boolean
}
type TurnCompleteNotification = NotificationBase & {
type: "turn-complete"
}
type ErrorNotification = NotificationBase & {
type: "error"
error: EventSessionError["properties"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
2026-01-07 11:38:08 +08:00
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
function pruneNotifications(list: Notification[]) {
const cutoff = Date.now() - NOTIFICATION_TTL_MS
const pruned = list.filter((n) => n.time >= cutoff)
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
2025-12-12 19:14:47 +08:00
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
const params = useParams()
2025-12-12 19:14:47 +08:00
const globalSDK = useGlobalSDK()
2025-12-16 00:13:29 +08:00
const globalSync = useGlobalSync()
2025-12-30 10:54:33 +08:00
const platform = usePlatform()
2026-01-07 06:03:39 +08:00
const settings = useSettings()
2026-01-21 00:43:43 +08:00
const language = useLanguage()
2025-12-12 19:14:47 +08:00
2026-01-26 20:06:10 +08:00
const empty: Notification[] = []
const currentDirectory = createMemo(() => {
2026-01-27 20:27:27 +08:00
return decode64(params.dir)
2026-01-26 20:06:10 +08:00
})
const currentSession = createMemo(() => params.id)
2025-12-18 03:10:57 +08:00
const [store, setStore, _, ready] = persisted(
2026-01-07 11:38:08 +08:00
Persist.global("notification", ["notification.v1"]),
2025-12-12 19:14:47 +08:00
createStore({
list: [] as Notification[],
}),
)
const meta = { pruned: false, disposed: false }
2026-01-07 11:38:08 +08:00
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
setStore("list", pruneNotifications(store.list))
})
const append = (notification: Notification) => {
setStore("list", (list) => pruneNotifications([...list, notification]))
}
2026-02-06 19:51:01 +08:00
const index = createMemo(() => buildNotificationIndex(store.list))
2026-01-26 20:06:10 +08:00
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return globalSDK.client.session
.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
playSound(soundSrc(settings.sounds.agent()))
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
playSound(soundSrc(settings.sounds.errors()))
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = globalSDK.event.listen((e) => {
2025-12-12 19:14:47 +08:00
const event = e.details
2026-01-26 20:06:10 +08:00
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
2025-12-12 19:14:47 +08:00
}
handleSessionError(directory, event, time)
2025-12-12 19:14:47 +08:00
})
onCleanup(() => {
meta.disposed = true
unsub()
})
2025-12-12 19:14:47 +08:00
return {
2025-12-18 03:10:57 +08:00
ready,
2025-12-12 19:14:47 +08:00
session: {
all(session: string) {
2026-01-26 20:06:10 +08:00
return index().session.all.get(session) ?? empty
2025-12-12 19:14:47 +08:00
},
unseen(session: string) {
2026-01-26 20:06:10 +08:00
return index().session.unseen.get(session) ?? empty
2025-12-12 19:14:47 +08:00
},
2026-02-06 19:51:01 +08:00
unseenCount(session: string) {
return index().session.unseenCount.get(session) ?? 0
},
unseenHasError(session: string) {
return index().session.unseenHasError.get(session) ?? false
},
2025-12-12 19:14:47 +08:00
markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true)
},
},
project: {
all(directory: string) {
2026-01-26 20:06:10 +08:00
return index().project.all.get(directory) ?? empty
2025-12-12 19:14:47 +08:00
},
unseen(directory: string) {
2026-01-26 20:06:10 +08:00
return index().project.unseen.get(directory) ?? empty
2025-12-12 19:14:47 +08:00
},
2026-02-06 19:51:01 +08:00
unseenCount(directory: string) {
return index().project.unseenCount.get(directory) ?? 0
},
unseenHasError(directory: string) {
return index().project.unseenHasError.get(directory) ?? false
},
2025-12-12 19:14:47 +08:00
markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true)
},
},
}
},
})