notification.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { createStore } from "solid-js/store"
  2. import { createEffect, onCleanup } from "solid-js"
  3. import { useParams } from "@solidjs/router"
  4. import { createSimpleContext } from "@opencode-ai/ui/context"
  5. import { useGlobalSDK } from "./global-sdk"
  6. import { useGlobalSync } from "./global-sync"
  7. import { usePlatform } from "@/context/platform"
  8. import { useLanguage } from "@/context/language"
  9. import { useSettings } from "@/context/settings"
  10. import { Binary } from "@opencode-ai/util/binary"
  11. import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
  12. import { EventSessionError } from "@opencode-ai/sdk/v2"
  13. import { Persist, persisted } from "@/utils/persist"
  14. import { playSound, soundSrc } from "@/utils/sound"
  15. type NotificationBase = {
  16. directory?: string
  17. session?: string
  18. metadata?: any
  19. time: number
  20. viewed: boolean
  21. }
  22. type TurnCompleteNotification = NotificationBase & {
  23. type: "turn-complete"
  24. }
  25. type ErrorNotification = NotificationBase & {
  26. type: "error"
  27. error: EventSessionError["properties"]["error"]
  28. }
  29. export type Notification = TurnCompleteNotification | ErrorNotification
  30. const MAX_NOTIFICATIONS = 500
  31. const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
  32. function pruneNotifications(list: Notification[]) {
  33. const cutoff = Date.now() - NOTIFICATION_TTL_MS
  34. const pruned = list.filter((n) => n.time >= cutoff)
  35. if (pruned.length <= MAX_NOTIFICATIONS) return pruned
  36. return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
  37. }
  38. export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
  39. name: "Notification",
  40. init: () => {
  41. const params = useParams()
  42. const globalSDK = useGlobalSDK()
  43. const globalSync = useGlobalSync()
  44. const platform = usePlatform()
  45. const settings = useSettings()
  46. const language = useLanguage()
  47. const [store, setStore, _, ready] = persisted(
  48. Persist.global("notification", ["notification.v1"]),
  49. createStore({
  50. list: [] as Notification[],
  51. }),
  52. )
  53. const meta = { pruned: false }
  54. createEffect(() => {
  55. if (!ready()) return
  56. if (meta.pruned) return
  57. meta.pruned = true
  58. setStore("list", pruneNotifications(store.list))
  59. })
  60. const append = (notification: Notification) => {
  61. setStore("list", (list) => pruneNotifications([...list, notification]))
  62. }
  63. const unsub = globalSDK.event.listen((e) => {
  64. const directory = e.name
  65. const event = e.details
  66. const time = Date.now()
  67. const activeDirectory = params.dir ? base64Decode(params.dir) : undefined
  68. const activeSession = params.id
  69. const viewed = (sessionID?: string) => {
  70. if (!activeDirectory) return false
  71. if (!activeSession) return false
  72. if (!sessionID) return false
  73. if (directory !== activeDirectory) return false
  74. return sessionID === activeSession
  75. }
  76. switch (event.type) {
  77. case "session.idle": {
  78. const sessionID = event.properties.sessionID
  79. const [syncStore] = globalSync.child(directory)
  80. const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
  81. const session = match.found ? syncStore.session[match.index] : undefined
  82. if (session?.parentID) break
  83. playSound(soundSrc(settings.sounds.agent()))
  84. append({
  85. directory,
  86. time,
  87. viewed: viewed(sessionID),
  88. type: "turn-complete",
  89. session: sessionID,
  90. })
  91. const href = `/${base64Encode(directory)}/session/${sessionID}`
  92. if (settings.notifications.agent()) {
  93. void platform.notify(
  94. language.t("notification.session.responseReady.title"),
  95. session?.title ?? sessionID,
  96. href,
  97. )
  98. }
  99. break
  100. }
  101. case "session.error": {
  102. const sessionID = event.properties.sessionID
  103. const [syncStore] = globalSync.child(directory)
  104. const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
  105. const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
  106. if (session?.parentID) break
  107. playSound(soundSrc(settings.sounds.errors()))
  108. const error = "error" in event.properties ? event.properties.error : undefined
  109. append({
  110. directory,
  111. time,
  112. viewed: viewed(sessionID),
  113. type: "error",
  114. session: sessionID ?? "global",
  115. error,
  116. })
  117. const description =
  118. session?.title ??
  119. (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
  120. const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
  121. if (settings.notifications.errors()) {
  122. void platform.notify(language.t("notification.session.error.title"), description, href)
  123. }
  124. break
  125. }
  126. }
  127. })
  128. onCleanup(unsub)
  129. return {
  130. ready,
  131. session: {
  132. all(session: string) {
  133. return store.list.filter((n) => n.session === session)
  134. },
  135. unseen(session: string) {
  136. return store.list.filter((n) => n.session === session && !n.viewed)
  137. },
  138. markViewed(session: string) {
  139. setStore("list", (n) => n.session === session, "viewed", true)
  140. },
  141. },
  142. project: {
  143. all(directory: string) {
  144. return store.list.filter((n) => n.directory === directory)
  145. },
  146. unseen(directory: string) {
  147. return store.list.filter((n) => n.directory === directory && !n.viewed)
  148. },
  149. markViewed(directory: string) {
  150. setStore("list", (n) => n.directory === directory, "viewed", true)
  151. },
  152. },
  153. }
  154. },
  155. })