sync.tsx 12 KB

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