sync.tsx 10 KB

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