MessagesContext.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import { createContext, useContext, useState, useCallback, type ReactNode } from "react"
  2. import { useEventHandler, type EventEmitter, type ServerEvent } from "../lib/api/events"
  3. import type { Message, Part, SDKMessage } from "../types/messages"
  4. import type { Permission } from "@opencode-ai/sdk/client"
  5. import * as Store from "../lib/messagesStore"
  6. import { sdk } from "../lib/api/sdkClient"
  7. import { useSession } from "./SessionContext"
  8. import { reloadPath } from "../lib/ideBridge"
  9. // Re-export types for convenience
  10. export type { Message, Part, SDKMessage } from "../types/messages"
  11. interface MessagesContextValue {
  12. messages: Message[]
  13. addMessage: (message: Message) => void
  14. updateMessage: (messageID: string, update: Partial<Message>) => void
  15. removeMessage: (messageID: string) => void
  16. addPart: (messageID: string, part: Part) => void
  17. updatePart: (messageID: string, partID: string, update: Partial<Part>) => void
  18. removePart: (messageID: string, partID: string) => void
  19. clearMessages: () => void
  20. getMessagesBySession: (sessionID: string) => Message[]
  21. loadSessionMessages: (sessionID: string) => Promise<void>
  22. setMessages: (messages: Message[]) => void
  23. // permissions
  24. permissions: Permission[]
  25. getPermissionForCall: (sessionID: string, callID?: string | null) => Permission | undefined
  26. respondPermission: (
  27. sessionID: string,
  28. permissionID: string,
  29. response: "once" | "always" | "reject",
  30. ) => Promise<boolean>
  31. }
  32. const MessagesContext = createContext<MessagesContextValue | undefined>(undefined)
  33. interface MessagesProviderProps {
  34. children: ReactNode
  35. emitter?: EventEmitter | null | undefined
  36. }
  37. export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
  38. const [messages, setMessages] = useState<Message[]>([])
  39. const [permissions, setPermissions] = useState<Permission[]>([])
  40. const session = useSession()
  41. const setReasoning = session.setReasoning
  42. // Add or update a message
  43. const addMessage = useCallback((message: Message) => {
  44. setMessages((prev) => Store.upsertMessage(prev, message))
  45. }, [])
  46. // Update a message
  47. const updateMessage = useCallback((messageID: string, update: Partial<Message>) => {
  48. setMessages((prev) => Store.updateMessage(prev, messageID, update))
  49. }, [])
  50. // Remove a message
  51. const removeMessage = useCallback((messageID: string) => {
  52. setMessages((prev) => Store.removeMessage(prev, messageID))
  53. }, [])
  54. // Add a part to a message
  55. const addPart = useCallback((messageID: string, part: Part) => {
  56. setMessages((prev) => Store.upsertPart(prev, messageID, part))
  57. }, [])
  58. // Update a specific part in a message
  59. const updatePart = useCallback((messageID: string, partID: string, update: Partial<Part>) => {
  60. setMessages((prev) => Store.updatePart(prev, messageID, partID, update))
  61. }, [])
  62. // Remove a part from a message
  63. const removePart = useCallback((messageID: string, partID: string) => {
  64. setMessages((prev) => Store.removePart(prev, messageID, partID))
  65. }, [])
  66. // Clear all messages
  67. const clearMessages = useCallback(() => {
  68. setMessages([])
  69. }, [])
  70. // Get messages for a specific session
  71. const getMessagesBySession = useCallback(
  72. (sessionID: string) => Store.getMessagesBySession(messages, sessionID),
  73. [messages],
  74. )
  75. // Listen to message.updated events (also handles message creation)
  76. const handleMessageUpdated = useCallback((event: ServerEvent) => {
  77. if (event.type === "message.updated") {
  78. const { info } = event.properties as { info: SDKMessage }
  79. console.log("[MessagesContext] Message updated:", info.id, info.role)
  80. // updateMessageInfo creates the message if it doesn't exist
  81. setMessages((prev) => Store.updateMessageInfo(prev, info.id, info))
  82. }
  83. }, [])
  84. // Listen to message.part.updated events
  85. const handlePartUpdated = useCallback(
  86. (event: ServerEvent) => {
  87. if (event.type === "message.part.updated") {
  88. const { part, delta } = event.properties as { part: Part; delta?: string }
  89. console.log(
  90. "[MessagesContext] Part updated:",
  91. part.id,
  92. part.type,
  93. delta ? `(delta: ${delta.length} chars)` : "",
  94. )
  95. if (delta && part.type === "text") {
  96. // Apply delta for streaming text
  97. setMessages((prev) => Store.applyPartDelta(prev, part.messageID, part, delta))
  98. } else {
  99. // No delta, just upsert the part normally
  100. addPart(part.messageID, part)
  101. }
  102. // Reload file in IDE when write/edit tool completes
  103. if (part.type === "tool") {
  104. const toolPart = part as { tool?: string; state?: { status?: string; input?: { filePath?: string } } }
  105. if (
  106. (toolPart.tool === "write" || toolPart.tool === "edit") &&
  107. toolPart.state?.status === "completed" &&
  108. toolPart.state?.input?.filePath
  109. ) {
  110. reloadPath(toolPart.state.input.filePath, toolPart.tool)
  111. }
  112. }
  113. if (part.type === "reasoning") {
  114. //const time = (part as { time?: { end?: number } }).time
  115. //const end = typeof time?.end === 'number'
  116. setReasoning(part.sessionID, true)
  117. } else {
  118. setReasoning(part.sessionID, false)
  119. }
  120. }
  121. },
  122. [addPart, setReasoning],
  123. )
  124. // Listen to message.removed events
  125. const handleMessageRemoved = useCallback(
  126. (event: ServerEvent) => {
  127. if (event.type === "message.removed") {
  128. const { sessionID, messageID } = event.properties as { sessionID: string; messageID: string }
  129. console.log("[MessagesContext] Message removed:", messageID)
  130. removeMessage(messageID)
  131. setReasoning(sessionID, false)
  132. }
  133. },
  134. [removeMessage, setReasoning],
  135. )
  136. // Listen to message.part.removed events
  137. const handlePartRemoved = useCallback(
  138. (event: ServerEvent) => {
  139. if (event.type === "message.part.removed") {
  140. const { sessionID, messageID, partID } = event.properties as {
  141. sessionID: string
  142. messageID: string
  143. partID: string
  144. }
  145. console.log("[MessagesContext] Part removed:", partID)
  146. removePart(messageID, partID)
  147. setReasoning(sessionID, false)
  148. }
  149. },
  150. [removePart, setReasoning],
  151. )
  152. // Load messages for a session
  153. const loadSessionMessages = useCallback(async (sessionID: string) => {
  154. // Skip loading for virtual sessions (not yet persisted to server)
  155. if (sessionID.startsWith("virtual-")) {
  156. console.log("[MessagesContext] Skipping load for virtual session:", sessionID)
  157. return
  158. }
  159. console.log("[MessagesContext] Loading messages for session:", sessionID)
  160. try {
  161. const response = await sdk.session.messages({ path: { id: sessionID } })
  162. if (response.error) {
  163. console.error("[MessagesContext] Failed to load messages:", response.error)
  164. return
  165. }
  166. if (response.data) {
  167. console.log("[MessagesContext] Messages loaded:", response.data.length)
  168. // SDK response is already in the correct format: Array<{ info: Message, parts: Array<Part> }>
  169. const loadedMessages: Message[] = response.data
  170. console.log("[MessagesContext] Loaded messages sample:", loadedMessages[0])
  171. // Replace messages for this session only if we received any; otherwise keep existing local state
  172. setMessages((prev) => {
  173. if (!loadedMessages || loadedMessages.length === 0) return prev
  174. const filtered = prev.filter((msg) => msg.info.sessionID !== sessionID)
  175. return [...filtered, ...loadedMessages]
  176. })
  177. try {
  178. const { currentSession, setIsIdle } = session
  179. if (currentSession?.id === sessionID) {
  180. const last = [...loadedMessages].sort((a, b) => a.info.time.created - b.info.time.created).at(-1)
  181. if (last) {
  182. const completed = (last.info as any)?.time?.completed
  183. const isAssistant = (last.info as any)?.role === "assistant"
  184. const busy = isAssistant && (!completed || completed === 0)
  185. setIsIdle(!busy)
  186. }
  187. }
  188. } catch (e) {}
  189. }
  190. } catch (err) {
  191. console.error("[MessagesContext] Failed to load messages:", err)
  192. }
  193. }, [])
  194. // Permission events
  195. const handlePermissionUpdated = useCallback((event: ServerEvent) => {
  196. if (event.type !== "permission.updated") return
  197. const perm = event.properties as Permission
  198. setPermissions((prev) => {
  199. const exists = prev.some((p) => p.id === perm.id)
  200. if (exists) return prev.map((p) => (p.id === perm.id ? perm : p))
  201. return [...prev, perm]
  202. })
  203. }, [])
  204. const handlePermissionReplied = useCallback((event: ServerEvent) => {
  205. if (event.type !== "permission.replied") return
  206. const { permissionID } = event.properties as { sessionID: string; permissionID: string; response: string }
  207. setPermissions((prev) => prev.filter((p) => p.id !== permissionID))
  208. }, [])
  209. const getPermissionForCall = useCallback(
  210. (sessionID: string, callID?: string | null) => {
  211. if (!sessionID || !callID) return undefined
  212. // Match by session + callID
  213. return permissions.find((p) => p.sessionID === sessionID && p.callID === callID)
  214. },
  215. [permissions],
  216. )
  217. const respondPermission = useCallback(
  218. async (sessionID: string, permissionID: string, response: "once" | "always" | "reject") => {
  219. try {
  220. const result = await sdk.permissions.respond({
  221. path: { id: sessionID, permissionID },
  222. body: { response },
  223. })
  224. const ok = Boolean(result && "data" in result && (result as any).data === true)
  225. if (ok) setPermissions((prev) => prev.filter((p) => p.id !== permissionID))
  226. return ok
  227. } catch (e) {
  228. return false
  229. }
  230. },
  231. [],
  232. )
  233. // Subscribe to events if emitter is provided
  234. useEventHandler(emitter ?? null, "message.updated", handleMessageUpdated)
  235. useEventHandler(emitter ?? null, "message.part.updated", handlePartUpdated)
  236. useEventHandler(emitter ?? null, "message.removed", handleMessageRemoved)
  237. useEventHandler(emitter ?? null, "message.part.removed", handlePartRemoved)
  238. useEventHandler(emitter ?? null, "permission.updated", handlePermissionUpdated)
  239. useEventHandler(emitter ?? null, "permission.replied", handlePermissionReplied)
  240. const value: MessagesContextValue = {
  241. messages,
  242. addMessage,
  243. updateMessage,
  244. removeMessage,
  245. addPart,
  246. updatePart,
  247. removePart,
  248. clearMessages,
  249. getMessagesBySession,
  250. loadSessionMessages,
  251. setMessages,
  252. permissions,
  253. getPermissionForCall,
  254. respondPermission,
  255. }
  256. return <MessagesContext.Provider value={value}>{children}</MessagesContext.Provider>
  257. }
  258. // eslint-disable-next-line react-refresh/only-export-components
  259. export function useMessages() {
  260. const context = useContext(MessagesContext)
  261. if (context === undefined) {
  262. throw new Error("useMessages must be used within a MessagesProvider")
  263. }
  264. return context
  265. }