Files
shopify-ai-backup/opencode/packages/app/src/context/notification.tsx
2026-02-07 20:54:46 +00:00

200 lines
6.6 KiB
TypeScript

import { createStore } from "solid-js/store"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { Binary } from "@opencode-ai/util/binary"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
import { buildNotificationIndex } from "./notification-index"
type NotificationBase = {
directory?: string
session?: string
metadata?: any
time: number
viewed: boolean
}
type TurnCompleteNotification = NotificationBase & {
type: "turn-complete"
}
type ErrorNotification = NotificationBase & {
type: "error"
error: EventSessionError["properties"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
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)
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const empty: Notification[] = []
const currentDirectory = createMemo(() => {
return decode64(params.dir)
})
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const meta = { pruned: false }
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]))
}
const index = createMemo(() => buildNotificationIndex(store.list))
const unsub = globalSDK.event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
const viewed = (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
}
switch (event.type) {
case "session.idle": {
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const session = match.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
playSound(soundSrc(settings.sounds.agent()))
append({
directory,
time,
viewed: viewed(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,
)
}
break
}
case "session.error": {
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
playSound(soundSrc(settings.sounds.errors()))
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewed(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)
}
break
}
}
})
onCleanup(unsub)
return {
ready,
session: {
all(session: string) {
return index().session.all.get(session) ?? empty
},
unseen(session: string) {
return index().session.unseen.get(session) ?? empty
},
unseenCount(session: string) {
return index().session.unseenCount.get(session) ?? 0
},
unseenHasError(session: string) {
return index().session.unseenHasError.get(session) ?? false
},
markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true)
},
},
project: {
all(directory: string) {
return index().project.all.get(directory) ?? empty
},
unseen(directory: string) {
return index().project.unseen.get(directory) ?? empty
},
unseenCount(directory: string) {
return index().project.unseenCount.get(directory) ?? 0
},
unseenHasError(directory: string) {
return index().project.unseenHasError.get(directory) ?? false
},
markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true)
},
},
}
},
})