notification.tsx 6.6 KB

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