sync.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import type {
  2. Message,
  3. Agent,
  4. Provider,
  5. Session,
  6. Part,
  7. Config,
  8. Todo,
  9. Command,
  10. PermissionRequest,
  11. QuestionRequest,
  12. LspStatus,
  13. McpStatus,
  14. McpResource,
  15. FormatterStatus,
  16. SessionStatus,
  17. ProviderListResponse,
  18. ProviderAuthMethod,
  19. VcsInfo,
  20. } from "@opencode-ai/sdk/v2"
  21. import { createStore, produce, reconcile } from "solid-js/store"
  22. import { useProject } from "@tui/context/project"
  23. import { useEvent } from "@tui/context/event"
  24. import { useSDK } from "@tui/context/sdk"
  25. import { Binary } from "@opencode-ai/core/util/binary"
  26. import { createSimpleContext } from "./helper"
  27. import type { Snapshot } from "@/snapshot"
  28. import { useExit } from "./exit"
  29. import { useArgs } from "./args"
  30. import { batch, onMount } from "solid-js"
  31. import * as Log from "@opencode-ai/core/util/log"
  32. import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
  33. import path from "path"
  34. import { useKV } from "./kv"
  35. export const { use: useSync, provider: SyncProvider } = createSimpleContext({
  36. name: "Sync",
  37. init: () => {
  38. const [store, setStore] = createStore<{
  39. status: "loading" | "partial" | "complete"
  40. provider: Provider[]
  41. provider_default: Record<string, string>
  42. provider_next: ProviderListResponse
  43. console_state: ConsoleState
  44. provider_auth: Record<string, ProviderAuthMethod[]>
  45. agent: Agent[]
  46. command: Command[]
  47. permission: {
  48. [sessionID: string]: PermissionRequest[]
  49. }
  50. question: {
  51. [sessionID: string]: QuestionRequest[]
  52. }
  53. config: Config
  54. session: Session[]
  55. session_status: {
  56. [sessionID: string]: SessionStatus
  57. }
  58. session_diff: {
  59. [sessionID: string]: Snapshot.FileDiff[]
  60. }
  61. todo: {
  62. [sessionID: string]: Todo[]
  63. }
  64. message: {
  65. [sessionID: string]: Message[]
  66. }
  67. part: {
  68. [messageID: string]: Part[]
  69. }
  70. lsp: LspStatus[]
  71. mcp: {
  72. [key: string]: McpStatus
  73. }
  74. mcp_resource: {
  75. [key: string]: McpResource
  76. }
  77. formatter: FormatterStatus[]
  78. vcs: VcsInfo | undefined
  79. }>({
  80. provider_next: {
  81. all: [],
  82. default: {},
  83. connected: [],
  84. },
  85. console_state: emptyConsoleState,
  86. provider_auth: {},
  87. config: {},
  88. status: "loading",
  89. agent: [],
  90. permission: {},
  91. question: {},
  92. command: [],
  93. provider: [],
  94. provider_default: {},
  95. session: [],
  96. session_status: {},
  97. session_diff: {},
  98. todo: {},
  99. message: {},
  100. part: {},
  101. lsp: [],
  102. mcp: {},
  103. mcp_resource: {},
  104. formatter: [],
  105. vcs: undefined,
  106. })
  107. const event = useEvent()
  108. const project = useProject()
  109. const sdk = useSDK()
  110. const kv = useKV()
  111. const fullSyncedSessions = new Set<string>()
  112. let syncedWorkspace = project.workspace.current()
  113. function sessionListQuery(): { scope?: "project"; path?: string } {
  114. if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
  115. if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
  116. return {
  117. path: path
  118. .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
  119. .replaceAll("\\", "/"),
  120. }
  121. }
  122. function listSessions() {
  123. return sdk.client.session
  124. .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
  125. .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
  126. }
  127. event.subscribe((event) => {
  128. switch (event.type) {
  129. case "server.instance.disposed":
  130. void bootstrap()
  131. break
  132. case "permission.replied": {
  133. const requests = store.permission[event.properties.sessionID]
  134. if (!requests) break
  135. const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
  136. if (!match.found) break
  137. setStore(
  138. "permission",
  139. event.properties.sessionID,
  140. produce((draft) => {
  141. draft.splice(match.index, 1)
  142. }),
  143. )
  144. break
  145. }
  146. case "permission.asked": {
  147. const request = event.properties
  148. const requests = store.permission[request.sessionID]
  149. if (!requests) {
  150. setStore("permission", request.sessionID, [request])
  151. break
  152. }
  153. const match = Binary.search(requests, request.id, (r) => r.id)
  154. if (match.found) {
  155. setStore("permission", request.sessionID, match.index, reconcile(request))
  156. break
  157. }
  158. setStore(
  159. "permission",
  160. request.sessionID,
  161. produce((draft) => {
  162. draft.splice(match.index, 0, request)
  163. }),
  164. )
  165. break
  166. }
  167. case "question.replied":
  168. case "question.rejected": {
  169. const requests = store.question[event.properties.sessionID]
  170. if (!requests) break
  171. const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
  172. if (!match.found) break
  173. setStore(
  174. "question",
  175. event.properties.sessionID,
  176. produce((draft) => {
  177. draft.splice(match.index, 1)
  178. }),
  179. )
  180. break
  181. }
  182. case "question.asked": {
  183. const request = event.properties
  184. const requests = store.question[request.sessionID]
  185. if (!requests) {
  186. setStore("question", request.sessionID, [request])
  187. break
  188. }
  189. const match = Binary.search(requests, request.id, (r) => r.id)
  190. if (match.found) {
  191. setStore("question", request.sessionID, match.index, reconcile(request))
  192. break
  193. }
  194. setStore(
  195. "question",
  196. request.sessionID,
  197. produce((draft) => {
  198. draft.splice(match.index, 0, request)
  199. }),
  200. )
  201. break
  202. }
  203. case "todo.updated":
  204. setStore("todo", event.properties.sessionID, event.properties.todos)
  205. break
  206. case "session.diff":
  207. setStore("session_diff", event.properties.sessionID, event.properties.diff)
  208. break
  209. case "session.deleted": {
  210. const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
  211. if (result.found) {
  212. setStore(
  213. "session",
  214. produce((draft) => {
  215. draft.splice(result.index, 1)
  216. }),
  217. )
  218. }
  219. break
  220. }
  221. case "session.updated": {
  222. const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
  223. if (result.found) {
  224. setStore("session", result.index, reconcile(event.properties.info))
  225. break
  226. }
  227. setStore(
  228. "session",
  229. produce((draft) => {
  230. draft.splice(result.index, 0, event.properties.info)
  231. }),
  232. )
  233. break
  234. }
  235. case "session.status": {
  236. setStore("session_status", event.properties.sessionID, event.properties.status)
  237. break
  238. }
  239. case "message.updated": {
  240. const messages = store.message[event.properties.info.sessionID]
  241. if (!messages) {
  242. setStore("message", event.properties.info.sessionID, [event.properties.info])
  243. break
  244. }
  245. const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
  246. if (result.found) {
  247. setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
  248. break
  249. }
  250. setStore(
  251. "message",
  252. event.properties.info.sessionID,
  253. produce((draft) => {
  254. draft.splice(result.index, 0, event.properties.info)
  255. }),
  256. )
  257. const updated = store.message[event.properties.info.sessionID]
  258. if (updated.length > 100) {
  259. const oldest = updated[0]
  260. batch(() => {
  261. setStore(
  262. "message",
  263. event.properties.info.sessionID,
  264. produce((draft) => {
  265. draft.shift()
  266. }),
  267. )
  268. setStore(
  269. "part",
  270. produce((draft) => {
  271. delete draft[oldest.id]
  272. }),
  273. )
  274. })
  275. }
  276. break
  277. }
  278. case "message.removed": {
  279. const messages = store.message[event.properties.sessionID]
  280. const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
  281. if (result.found) {
  282. setStore(
  283. "message",
  284. event.properties.sessionID,
  285. produce((draft) => {
  286. draft.splice(result.index, 1)
  287. }),
  288. )
  289. }
  290. break
  291. }
  292. case "message.part.updated": {
  293. const parts = store.part[event.properties.part.messageID]
  294. if (!parts) {
  295. setStore("part", event.properties.part.messageID, [event.properties.part])
  296. break
  297. }
  298. const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
  299. if (result.found) {
  300. setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
  301. break
  302. }
  303. setStore(
  304. "part",
  305. event.properties.part.messageID,
  306. produce((draft) => {
  307. draft.splice(result.index, 0, event.properties.part)
  308. }),
  309. )
  310. break
  311. }
  312. case "message.part.delta": {
  313. const parts = store.part[event.properties.messageID]
  314. if (!parts) break
  315. const result = Binary.search(parts, event.properties.partID, (p) => p.id)
  316. if (!result.found) break
  317. setStore(
  318. "part",
  319. event.properties.messageID,
  320. produce((draft) => {
  321. const part = draft[result.index]
  322. const field = event.properties.field as keyof typeof part
  323. const existing = part[field] as string | undefined
  324. ;(part[field] as string) = (existing ?? "") + event.properties.delta
  325. }),
  326. )
  327. break
  328. }
  329. case "message.part.removed": {
  330. const parts = store.part[event.properties.messageID]
  331. const result = Binary.search(parts, event.properties.partID, (p) => p.id)
  332. if (result.found)
  333. setStore(
  334. "part",
  335. event.properties.messageID,
  336. produce((draft) => {
  337. draft.splice(result.index, 1)
  338. }),
  339. )
  340. break
  341. }
  342. case "lsp.updated": {
  343. const workspace = project.workspace.current()
  344. void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
  345. break
  346. }
  347. case "vcs.branch.updated": {
  348. setStore("vcs", { branch: event.properties.branch })
  349. break
  350. }
  351. }
  352. })
  353. const exit = useExit()
  354. const args = useArgs()
  355. async function bootstrap(input: { fatal?: boolean } = {}) {
  356. const fatal = input.fatal ?? true
  357. const workspace = project.workspace.current()
  358. if (workspace !== syncedWorkspace) {
  359. fullSyncedSessions.clear()
  360. syncedWorkspace = workspace
  361. }
  362. const projectPromise = project.sync()
  363. const sessionListPromise = projectPromise.then(() => listSessions())
  364. // blocking - include session.list when continuing a session
  365. const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
  366. const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
  367. const consoleStatePromise = sdk.client.experimental.console
  368. .get({ workspace }, { throwOnError: true })
  369. .then((x) => x.data)
  370. .catch(() => emptyConsoleState)
  371. const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
  372. const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
  373. const blockingRequests: Promise<unknown>[] = [
  374. providersPromise,
  375. providerListPromise,
  376. agentsPromise,
  377. configPromise,
  378. projectPromise,
  379. ...(args.continue ? [sessionListPromise] : []),
  380. ]
  381. await Promise.all(blockingRequests)
  382. .then(async () => {
  383. const providersResponse = providersPromise.then((x) => x.data!)
  384. const providerListResponse = providerListPromise.then((x) => x.data!)
  385. const consoleStateResponse = consoleStatePromise
  386. const agentsResponse = agentsPromise.then((x) => x.data ?? [])
  387. const configResponse = configPromise.then((x) => x.data!)
  388. const sessionListResponse = args.continue ? sessionListPromise : undefined
  389. return Promise.all([
  390. providersResponse,
  391. providerListResponse,
  392. consoleStateResponse,
  393. agentsResponse,
  394. configResponse,
  395. ...(sessionListResponse ? [sessionListResponse] : []),
  396. ]).then((responses) => {
  397. const providers = responses[0]
  398. const providerList = responses[1]
  399. const consoleState = responses[2]
  400. const agents = responses[3]
  401. const config = responses[4]
  402. const sessions = responses[5]
  403. batch(() => {
  404. setStore("provider", reconcile(providers.providers))
  405. setStore("provider_default", reconcile(providers.default))
  406. setStore("provider_next", reconcile(providerList))
  407. setStore("console_state", reconcile(consoleState))
  408. setStore("agent", reconcile(agents))
  409. setStore("config", reconcile(config))
  410. if (sessions !== undefined) setStore("session", reconcile(sessions))
  411. })
  412. })
  413. })
  414. .then(() => {
  415. if (store.status !== "complete") setStore("status", "partial")
  416. // non-blocking
  417. void Promise.all([
  418. ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
  419. consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
  420. sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
  421. sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
  422. sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
  423. sdk.client.experimental.resource
  424. .list({ workspace })
  425. .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
  426. sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
  427. sdk.client.session.status({ workspace }).then((x) => {
  428. setStore("session_status", reconcile(x.data ?? {}))
  429. }),
  430. sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
  431. sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
  432. project.workspace.sync(),
  433. ]).then(() => {
  434. setStore("status", "complete")
  435. })
  436. })
  437. .catch(async (e) => {
  438. Log.Default.error("tui bootstrap failed", {
  439. error: e instanceof Error ? e.message : String(e),
  440. name: e instanceof Error ? e.name : undefined,
  441. stack: e instanceof Error ? e.stack : undefined,
  442. })
  443. if (fatal) {
  444. await exit(e)
  445. } else {
  446. throw e
  447. }
  448. })
  449. }
  450. onMount(() => {
  451. void bootstrap()
  452. })
  453. const result = {
  454. data: store,
  455. set: setStore,
  456. get status() {
  457. return store.status
  458. },
  459. get ready() {
  460. if (process.env.OPENCODE_FAST_BOOT) return true
  461. return store.status !== "loading"
  462. },
  463. get path() {
  464. return project.instance.path()
  465. },
  466. session: {
  467. get(sessionID: string) {
  468. const match = Binary.search(store.session, sessionID, (s) => s.id)
  469. if (match.found) return store.session[match.index]
  470. return undefined
  471. },
  472. query() {
  473. return sessionListQuery()
  474. },
  475. async refresh() {
  476. const list = await listSessions()
  477. setStore("session", reconcile(list))
  478. },
  479. status(sessionID: string) {
  480. const session = result.session.get(sessionID)
  481. if (!session) return "idle"
  482. if (session.time.compacting) return "compacting"
  483. const messages = store.message[sessionID] ?? []
  484. const last = messages.at(-1)
  485. if (!last) return "idle"
  486. if (last.role === "user") return "working"
  487. return last.time.completed ? "idle" : "working"
  488. },
  489. async sync(sessionID: string) {
  490. if (fullSyncedSessions.has(sessionID)) return
  491. const [session, messages, todo, diff] = await Promise.all([
  492. sdk.client.session.get({ sessionID }, { throwOnError: true }),
  493. sdk.client.session.messages({ sessionID, limit: 100 }),
  494. sdk.client.session.todo({ sessionID }),
  495. sdk.client.session.diff({ sessionID }),
  496. ])
  497. setStore(
  498. produce((draft) => {
  499. const match = Binary.search(draft.session, sessionID, (s) => s.id)
  500. if (match.found) draft.session[match.index] = session.data!
  501. if (!match.found) draft.session.splice(match.index, 0, session.data!)
  502. draft.todo[sessionID] = todo.data ?? []
  503. const infos: (typeof draft.message)[string] = []
  504. for (const message of messages.data ?? []) {
  505. infos.push(message.info)
  506. draft.part[message.info.id] = message.parts
  507. }
  508. draft.message[sessionID] = infos
  509. draft.session_diff[sessionID] = diff.data ?? []
  510. }),
  511. )
  512. fullSyncedSessions.add(sessionID)
  513. },
  514. },
  515. bootstrap,
  516. }
  517. return result
  518. },
  519. })