sync.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { batch, createMemo } from "solid-js"
  2. import { createStore, produce, reconcile } from "solid-js/store"
  3. import { Binary } from "@opencode-ai/util/binary"
  4. import { retry } from "@opencode-ai/util/retry"
  5. import { createSimpleContext } from "@opencode-ai/ui/context"
  6. import { useGlobalSync } from "./global-sync"
  7. import { useSDK } from "./sdk"
  8. import type { Message, Part } from "@opencode-ai/sdk/v2/client"
  9. export const { use: useSync, provider: SyncProvider } = createSimpleContext({
  10. name: "Sync",
  11. init: () => {
  12. const globalSync = useGlobalSync()
  13. const sdk = useSDK()
  14. const [store, setStore] = globalSync.child(sdk.directory)
  15. const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
  16. const chunk = 400
  17. const inflight = new Map<string, Promise<void>>()
  18. const inflightDiff = new Map<string, Promise<void>>()
  19. const inflightTodo = new Map<string, Promise<void>>()
  20. const [meta, setMeta] = createStore({
  21. limit: {} as Record<string, number>,
  22. complete: {} as Record<string, boolean>,
  23. loading: {} as Record<string, boolean>,
  24. })
  25. const getSession = (sessionID: string) => {
  26. const match = Binary.search(store.session, sessionID, (s) => s.id)
  27. if (match.found) return store.session[match.index]
  28. return undefined
  29. }
  30. const limitFor = (count: number) => {
  31. if (count <= chunk) return chunk
  32. return Math.ceil(count / chunk) * chunk
  33. }
  34. const hydrateMessages = (sessionID: string) => {
  35. if (meta.limit[sessionID] !== undefined) return
  36. const messages = store.message[sessionID]
  37. if (!messages) return
  38. const limit = limitFor(messages.length)
  39. setMeta("limit", sessionID, limit)
  40. setMeta("complete", sessionID, messages.length < limit)
  41. }
  42. const loadMessages = async (sessionID: string, limit: number) => {
  43. if (meta.loading[sessionID]) return
  44. setMeta("loading", sessionID, true)
  45. await retry(() => sdk.client.session.messages({ sessionID, limit }))
  46. .then((messages) => {
  47. const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
  48. const next = items
  49. .map((x) => x.info)
  50. .filter((m) => !!m?.id)
  51. .slice()
  52. .sort((a, b) => a.id.localeCompare(b.id))
  53. batch(() => {
  54. setStore("message", sessionID, reconcile(next, { key: "id" }))
  55. for (const message of items) {
  56. setStore(
  57. "part",
  58. message.info.id,
  59. reconcile(
  60. message.parts
  61. .filter((p) => !!p?.id)
  62. .slice()
  63. .sort((a, b) => a.id.localeCompare(b.id)),
  64. { key: "id" },
  65. ),
  66. )
  67. }
  68. setMeta("limit", sessionID, limit)
  69. setMeta("complete", sessionID, next.length < limit)
  70. })
  71. })
  72. .finally(() => {
  73. setMeta("loading", sessionID, false)
  74. })
  75. }
  76. return {
  77. data: store,
  78. set: setStore,
  79. get status() {
  80. return store.status
  81. },
  82. get ready() {
  83. return store.status !== "loading"
  84. },
  85. get project() {
  86. const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
  87. if (match.found) return globalSync.data.project[match.index]
  88. return undefined
  89. },
  90. session: {
  91. get: getSession,
  92. addOptimisticMessage(input: {
  93. sessionID: string
  94. messageID: string
  95. parts: Part[]
  96. agent: string
  97. model: { providerID: string; modelID: string }
  98. }) {
  99. const message: Message = {
  100. id: input.messageID,
  101. sessionID: input.sessionID,
  102. role: "user",
  103. time: { created: Date.now() },
  104. agent: input.agent,
  105. model: input.model,
  106. }
  107. setStore(
  108. produce((draft) => {
  109. const messages = draft.message[input.sessionID]
  110. if (!messages) {
  111. draft.message[input.sessionID] = [message]
  112. } else {
  113. const result = Binary.search(messages, input.messageID, (m) => m.id)
  114. messages.splice(result.index, 0, message)
  115. }
  116. draft.part[input.messageID] = input.parts
  117. .filter((p) => !!p?.id)
  118. .slice()
  119. .sort((a, b) => a.id.localeCompare(b.id))
  120. }),
  121. )
  122. },
  123. async sync(sessionID: string) {
  124. const hasSession = getSession(sessionID) !== undefined
  125. hydrateMessages(sessionID)
  126. const hasMessages = store.message[sessionID] !== undefined
  127. if (hasSession && hasMessages) return
  128. const pending = inflight.get(sessionID)
  129. if (pending) return pending
  130. const limit = meta.limit[sessionID] ?? chunk
  131. const sessionReq = hasSession
  132. ? Promise.resolve()
  133. : retry(() => sdk.client.session.get({ sessionID })).then((session) => {
  134. const data = session.data
  135. if (!data) return
  136. setStore(
  137. "session",
  138. produce((draft) => {
  139. const match = Binary.search(draft, sessionID, (s) => s.id)
  140. if (match.found) {
  141. draft[match.index] = data
  142. return
  143. }
  144. draft.splice(match.index, 0, data)
  145. }),
  146. )
  147. })
  148. const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
  149. const promise = Promise.all([sessionReq, messagesReq])
  150. .then(() => {})
  151. .finally(() => {
  152. inflight.delete(sessionID)
  153. })
  154. inflight.set(sessionID, promise)
  155. return promise
  156. },
  157. async diff(sessionID: string) {
  158. if (store.session_diff[sessionID] !== undefined) return
  159. const pending = inflightDiff.get(sessionID)
  160. if (pending) return pending
  161. const promise = retry(() => sdk.client.session.diff({ sessionID }))
  162. .then((diff) => {
  163. setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
  164. })
  165. .finally(() => {
  166. inflightDiff.delete(sessionID)
  167. })
  168. inflightDiff.set(sessionID, promise)
  169. return promise
  170. },
  171. async todo(sessionID: string) {
  172. if (store.todo[sessionID] !== undefined) return
  173. const pending = inflightTodo.get(sessionID)
  174. if (pending) return pending
  175. const promise = retry(() => sdk.client.session.todo({ sessionID }))
  176. .then((todo) => {
  177. setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
  178. })
  179. .finally(() => {
  180. inflightTodo.delete(sessionID)
  181. })
  182. inflightTodo.set(sessionID, promise)
  183. return promise
  184. },
  185. history: {
  186. more(sessionID: string) {
  187. if (store.message[sessionID] === undefined) return false
  188. if (meta.limit[sessionID] === undefined) return false
  189. if (meta.complete[sessionID]) return false
  190. return true
  191. },
  192. loading(sessionID: string) {
  193. return meta.loading[sessionID] ?? false
  194. },
  195. async loadMore(sessionID: string, count = chunk) {
  196. if (meta.loading[sessionID]) return
  197. if (meta.complete[sessionID]) return
  198. const current = meta.limit[sessionID] ?? chunk
  199. await loadMessages(sessionID, current + count)
  200. },
  201. },
  202. fetch: async (count = 10) => {
  203. setStore("limit", (x) => x + count)
  204. await sdk.client.session.list().then((x) => {
  205. const sessions = (x.data ?? [])
  206. .filter((s) => !!s?.id)
  207. .slice()
  208. .sort((a, b) => a.id.localeCompare(b.id))
  209. .slice(0, store.limit)
  210. setStore("session", reconcile(sessions, { key: "id" }))
  211. })
  212. },
  213. more: createMemo(() => store.session.length >= store.limit),
  214. archive: async (sessionID: string) => {
  215. await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
  216. setStore(
  217. produce((draft) => {
  218. const match = Binary.search(draft.session, sessionID, (s) => s.id)
  219. if (match.found) draft.session.splice(match.index, 1)
  220. }),
  221. )
  222. },
  223. },
  224. absolute,
  225. get directory() {
  226. return store.path.directory
  227. },
  228. }
  229. },
  230. })