sync.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  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. SnapshotFileDiff,
  21. ConsoleState,
  22. } from "@opencode-ai/sdk/v2"
  23. import { createStore, produce, reconcile } from "solid-js/store"
  24. import { useProject } from "./project"
  25. import { useEvent } from "./event"
  26. import { useSDK } from "./sdk"
  27. import { useTuiStartup } from "./runtime"
  28. import { createSimpleContext } from "./helper"
  29. import { useExit } from "./exit"
  30. import { useArgs } from "./args"
  31. import { batch, onMount } from "solid-js"
  32. import path from "path"
  33. import { useKV } from "./kv"
  34. import { usePermission } from "./permission"
  35. const emptyConsoleState: ConsoleState = {
  36. consoleManagedProviders: [],
  37. switchableOrgCount: 0,
  38. }
  39. function search<T>(items: T[], target: string, key: (item: T) => string) {
  40. let left = 0
  41. let right = items.length - 1
  42. while (left <= right) {
  43. const middle = Math.floor((left + right) / 2)
  44. const value = key(items[middle])
  45. if (value === target) return { found: true, index: middle }
  46. if (value < target) left = middle + 1
  47. else right = middle - 1
  48. }
  49. return { found: false, index: left }
  50. }
  51. export const {
  52. context: SyncContext,
  53. use: useSync,
  54. provider: SyncProvider,
  55. } = createSimpleContext({
  56. name: "Sync",
  57. init: () => {
  58. const startup = useTuiStartup()
  59. const kv = useKV()
  60. const permission = usePermission()
  61. const [store, setStore] = createStore<{
  62. status: "loading" | "partial" | "complete"
  63. provider: Provider[]
  64. provider_default: Record<string, string>
  65. provider_next: ProviderListResponse
  66. console_state: ConsoleState
  67. capabilities: {
  68. experimentalBackgroundSubagents: boolean
  69. }
  70. provider_auth: Record<string, ProviderAuthMethod[]>
  71. agent: Agent[]
  72. command: Command[]
  73. permission: {
  74. [sessionID: string]: PermissionRequest[]
  75. }
  76. question: {
  77. [sessionID: string]: QuestionRequest[]
  78. }
  79. config: Config
  80. session: Session[]
  81. session_status: {
  82. [sessionID: string]: SessionStatus
  83. }
  84. session_diff: {
  85. [sessionID: string]: SnapshotFileDiff[]
  86. }
  87. todo: {
  88. [sessionID: string]: Todo[]
  89. }
  90. message: {
  91. [sessionID: string]: Message[]
  92. }
  93. part: {
  94. [messageID: string]: Part[]
  95. }
  96. lsp: LspStatus[]
  97. mcp: {
  98. [key: string]: McpStatus
  99. }
  100. mcp_resource: {
  101. [key: string]: McpResource
  102. }
  103. formatter: FormatterStatus[]
  104. vcs: VcsInfo | undefined
  105. }>({
  106. provider_next: {
  107. all: [],
  108. default: {},
  109. connected: [],
  110. },
  111. console_state: emptyConsoleState,
  112. capabilities: {
  113. experimentalBackgroundSubagents: false,
  114. },
  115. provider_auth: {},
  116. config: {},
  117. status: "loading",
  118. agent: [],
  119. permission: {},
  120. question: {},
  121. command: [],
  122. provider: [],
  123. provider_default: {},
  124. session: [],
  125. session_status: {},
  126. session_diff: {},
  127. todo: {},
  128. message: {},
  129. part: {},
  130. lsp: [],
  131. mcp: {},
  132. mcp_resource: {},
  133. formatter: [],
  134. vcs: undefined,
  135. })
  136. const event = useEvent()
  137. const project = useProject()
  138. const sdk = useSDK()
  139. const fullSyncedSessions = new Set<string>()
  140. const syncingSessions = new Map<string, Promise<void>>()
  141. const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
  142. const touchMessage = (sessionID: string, messageID: string) => {
  143. hydratingSessions.get(sessionID)?.messages.add(messageID)
  144. }
  145. const touchPart = (sessionID: string, partID: string) => {
  146. hydratingSessions.get(sessionID)?.parts.add(partID)
  147. }
  148. function sessionListQuery(): { scope?: "project"; path?: string } {
  149. if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
  150. if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
  151. return {
  152. path: path
  153. .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
  154. .replaceAll("\\", "/"),
  155. }
  156. }
  157. function listSessions() {
  158. return sdk.client.session
  159. .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
  160. .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
  161. }
  162. event.subscribe((event, { directory, workspace }) => {
  163. switch (event.type) {
  164. case "server.instance.disposed":
  165. void bootstrap()
  166. break
  167. case "permission.replied": {
  168. const requests = store.permission[event.properties.sessionID]
  169. if (!requests) break
  170. const match = search(requests, event.properties.requestID, (r) => r.id)
  171. if (!match.found) break
  172. setStore(
  173. "permission",
  174. event.properties.sessionID,
  175. produce((draft) => {
  176. draft.splice(match.index, 1)
  177. }),
  178. )
  179. break
  180. }
  181. case "permission.asked": {
  182. const request = event.properties
  183. if (permission.mode === "auto") {
  184. void sdk.client.permission.reply({
  185. requestID: request.id,
  186. reply: "once",
  187. directory,
  188. workspace,
  189. })
  190. break
  191. }
  192. const requests = store.permission[request.sessionID]
  193. if (!requests) {
  194. setStore("permission", request.sessionID, [request])
  195. break
  196. }
  197. const match = search(requests, request.id, (r) => r.id)
  198. if (match.found) {
  199. setStore("permission", request.sessionID, match.index, reconcile(request))
  200. break
  201. }
  202. setStore(
  203. "permission",
  204. request.sessionID,
  205. produce((draft) => {
  206. draft.splice(match.index, 0, request)
  207. }),
  208. )
  209. break
  210. }
  211. case "question.replied":
  212. case "question.rejected": {
  213. const requests = store.question[event.properties.sessionID]
  214. if (!requests) break
  215. const match = search(requests, event.properties.requestID, (r) => r.id)
  216. if (!match.found) break
  217. setStore(
  218. "question",
  219. event.properties.sessionID,
  220. produce((draft) => {
  221. draft.splice(match.index, 1)
  222. }),
  223. )
  224. break
  225. }
  226. case "question.asked": {
  227. const request = event.properties
  228. const requests = store.question[request.sessionID]
  229. if (!requests) {
  230. setStore("question", request.sessionID, [request])
  231. break
  232. }
  233. const match = search(requests, request.id, (r) => r.id)
  234. if (match.found) {
  235. setStore("question", request.sessionID, match.index, reconcile(request))
  236. break
  237. }
  238. setStore(
  239. "question",
  240. request.sessionID,
  241. produce((draft) => {
  242. draft.splice(match.index, 0, request)
  243. }),
  244. )
  245. break
  246. }
  247. case "todo.updated":
  248. setStore("todo", event.properties.sessionID, event.properties.todos)
  249. break
  250. case "session.diff":
  251. setStore("session_diff", event.properties.sessionID, event.properties.diff)
  252. break
  253. case "session.deleted": {
  254. const result = search(store.session, event.properties.info.id, (s) => s.id)
  255. if (result.found) {
  256. setStore(
  257. "session",
  258. produce((draft) => {
  259. draft.splice(result.index, 1)
  260. }),
  261. )
  262. }
  263. break
  264. }
  265. case "session.updated": {
  266. const result = search(store.session, event.properties.info.id, (s) => s.id)
  267. if (result.found) {
  268. setStore("session", result.index, reconcile(event.properties.info))
  269. break
  270. }
  271. setStore(
  272. "session",
  273. produce((draft) => {
  274. draft.splice(result.index, 0, event.properties.info)
  275. }),
  276. )
  277. break
  278. }
  279. case "session.next.moved": {
  280. const result = search(store.session, event.properties.sessionID, (s) => s.id)
  281. if (!result.found) break
  282. setStore(
  283. "session",
  284. result.index,
  285. produce((session) => {
  286. session.directory = event.properties.location.directory
  287. session.path = event.properties.subdirectory
  288. session.workspaceID = event.properties.location.workspaceID
  289. session.time.updated = event.properties.timestamp
  290. }),
  291. )
  292. break
  293. }
  294. case "session.status": {
  295. setStore("session_status", event.properties.sessionID, event.properties.status)
  296. break
  297. }
  298. case "message.updated": {
  299. touchMessage(event.properties.info.sessionID, event.properties.info.id)
  300. const messages = store.message[event.properties.info.sessionID]
  301. if (!messages) {
  302. setStore("message", event.properties.info.sessionID, [event.properties.info])
  303. break
  304. }
  305. const result = search(messages, event.properties.info.id, (m) => m.id)
  306. if (result.found) {
  307. setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
  308. break
  309. }
  310. setStore(
  311. "message",
  312. event.properties.info.sessionID,
  313. produce((draft) => {
  314. draft.splice(result.index, 0, event.properties.info)
  315. }),
  316. )
  317. const updated = store.message[event.properties.info.sessionID]
  318. if (updated.length > 100) {
  319. const oldest = updated[0]
  320. batch(() => {
  321. setStore(
  322. "message",
  323. event.properties.info.sessionID,
  324. produce((draft) => {
  325. draft.shift()
  326. }),
  327. )
  328. setStore(
  329. "part",
  330. produce((draft) => {
  331. delete draft[oldest.id]
  332. }),
  333. )
  334. })
  335. }
  336. break
  337. }
  338. case "message.removed": {
  339. touchMessage(event.properties.sessionID, event.properties.messageID)
  340. const messages = store.message[event.properties.sessionID]
  341. const result = search(messages, event.properties.messageID, (m) => m.id)
  342. if (result.found) {
  343. setStore(
  344. "message",
  345. event.properties.sessionID,
  346. produce((draft) => {
  347. draft.splice(result.index, 1)
  348. }),
  349. )
  350. }
  351. break
  352. }
  353. case "message.part.updated": {
  354. touchPart(event.properties.part.sessionID, event.properties.part.id)
  355. const parts = store.part[event.properties.part.messageID]
  356. if (!parts) {
  357. setStore("part", event.properties.part.messageID, [event.properties.part])
  358. break
  359. }
  360. const result = search(parts, event.properties.part.id, (p) => p.id)
  361. if (result.found) {
  362. setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
  363. break
  364. }
  365. setStore(
  366. "part",
  367. event.properties.part.messageID,
  368. produce((draft) => {
  369. draft.splice(result.index, 0, event.properties.part)
  370. }),
  371. )
  372. break
  373. }
  374. case "message.part.delta": {
  375. const parts = store.part[event.properties.messageID]
  376. if (!parts) break
  377. const result = search(parts, event.properties.partID, (p) => p.id)
  378. if (!result.found) break
  379. touchPart(event.properties.sessionID, event.properties.partID)
  380. setStore(
  381. "part",
  382. event.properties.messageID,
  383. produce((draft) => {
  384. const part = draft[result.index]
  385. const field = event.properties.field as keyof typeof part
  386. const existing = part[field] as string | undefined
  387. ;(part[field] as string) = (existing ?? "") + event.properties.delta
  388. }),
  389. )
  390. break
  391. }
  392. case "message.part.removed": {
  393. touchPart(event.properties.sessionID, event.properties.partID)
  394. const parts = store.part[event.properties.messageID]
  395. const result = search(parts, event.properties.partID, (p) => p.id)
  396. if (result.found) {
  397. setStore(
  398. "part",
  399. event.properties.messageID,
  400. produce((draft) => {
  401. draft.splice(result.index, 1)
  402. }),
  403. )
  404. }
  405. break
  406. }
  407. case "lsp.updated": {
  408. const workspace = project.workspace.current()
  409. void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
  410. break
  411. }
  412. case "vcs.branch.updated": {
  413. if (workspace === project.workspace.current()) {
  414. setStore("vcs", { branch: event.properties.branch })
  415. }
  416. break
  417. }
  418. }
  419. })
  420. const exit = useExit()
  421. const args = useArgs()
  422. async function bootstrap(input: { fatal?: boolean } = {}) {
  423. const fatal = input.fatal ?? true
  424. const workspace = project.workspace.current()
  425. const projectPromise = project.sync()
  426. const sessionListPromise = projectPromise.then(() => listSessions())
  427. // blocking - include session.list when continuing a session
  428. const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
  429. const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
  430. const capabilitiesPromise = sdk.client.experimental.capabilities
  431. .get({ workspace }, { throwOnError: true })
  432. .then((x) => x.data)
  433. .catch(() => undefined)
  434. const consoleStatePromise = sdk.client.experimental.console
  435. .get({ workspace }, { throwOnError: true })
  436. .then((x) => x.data)
  437. .catch(() => emptyConsoleState)
  438. const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
  439. const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
  440. await Promise.all([
  441. providersPromise,
  442. providerListPromise,
  443. capabilitiesPromise,
  444. agentsPromise,
  445. configPromise,
  446. projectPromise,
  447. ...(args.continue ? [sessionListPromise] : []),
  448. ])
  449. .then(async () => {
  450. const providersResponse = providersPromise.then((x) => x.data!)
  451. const providerListResponse = providerListPromise.then((x) => x.data!)
  452. const capabilitiesResponse = capabilitiesPromise
  453. const consoleStateResponse = consoleStatePromise
  454. const agentsResponse = agentsPromise.then((x) => x.data ?? [])
  455. const configResponse = configPromise.then((x) => x.data!)
  456. const sessionListResponse = args.continue ? sessionListPromise : undefined
  457. return Promise.all([
  458. providersResponse,
  459. providerListResponse,
  460. capabilitiesResponse,
  461. consoleStateResponse,
  462. agentsResponse,
  463. configResponse,
  464. ...(sessionListResponse ? [sessionListResponse] : []),
  465. ]).then((responses) => {
  466. const providers = responses[0]
  467. const providerList = responses[1]
  468. const capabilities = responses[2]
  469. const consoleState = responses[3]
  470. const agents = responses[4]
  471. const config = responses[5]
  472. const sessions = responses[6]
  473. batch(() => {
  474. setStore("provider", reconcile(providers.providers))
  475. setStore("provider_default", reconcile(providers.default))
  476. setStore("provider_next", reconcile(providerList))
  477. setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
  478. setStore("console_state", reconcile(consoleState))
  479. setStore("agent", reconcile(agents))
  480. setStore("config", reconcile(config))
  481. if (sessions !== undefined) setStore("session", reconcile(sessions))
  482. })
  483. })
  484. })
  485. .then(() => {
  486. if (store.status !== "complete") setStore("status", "partial")
  487. // non-blocking
  488. void Promise.all([
  489. ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
  490. consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
  491. sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
  492. sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
  493. sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
  494. sdk.client.experimental.resource
  495. .list({ workspace })
  496. .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
  497. sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
  498. sdk.client.session.status({ workspace }).then((x) => {
  499. setStore("session_status", reconcile(x.data ?? {}))
  500. }),
  501. sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
  502. sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
  503. project.workspace.sync(),
  504. ]).then(() => {
  505. setStore("status", "complete")
  506. })
  507. })
  508. .catch(async (e) => {
  509. console.error("tui bootstrap failed", {
  510. error: e instanceof Error ? e.message : String(e),
  511. name: e instanceof Error ? e.name : undefined,
  512. stack: e instanceof Error ? e.stack : undefined,
  513. })
  514. if (fatal) {
  515. exit(e)
  516. } else {
  517. throw e
  518. }
  519. })
  520. }
  521. onMount(() => {
  522. void bootstrap()
  523. })
  524. const result = {
  525. data: store,
  526. set: setStore,
  527. get status() {
  528. return store.status
  529. },
  530. get ready() {
  531. if (startup.skipInitialLoading) return true
  532. return store.status !== "loading"
  533. },
  534. get path() {
  535. return project.instance.path()
  536. },
  537. session: {
  538. get(sessionID: string) {
  539. const match = search(store.session, sessionID, (s) => s.id)
  540. if (match.found) return store.session[match.index]
  541. return undefined
  542. },
  543. query() {
  544. return sessionListQuery()
  545. },
  546. async refresh() {
  547. const list = await listSessions()
  548. setStore("session", reconcile(list))
  549. },
  550. status(sessionID: string) {
  551. const session = result.session.get(sessionID)
  552. if (!session) return "idle"
  553. if (session.time.compacting) return "compacting"
  554. const messages = store.message[sessionID] ?? []
  555. const last = messages.at(-1)
  556. if (!last) return "idle"
  557. if (last.role === "user") return "working"
  558. return last.time.completed ? "idle" : "working"
  559. },
  560. async sync(sessionID: string) {
  561. if (fullSyncedSessions.has(sessionID)) return
  562. const syncing = syncingSessions.get(sessionID)
  563. if (syncing) return syncing
  564. const tracker = { messages: new Set<string>(), parts: new Set<string>() }
  565. hydratingSessions.set(sessionID, tracker)
  566. const task = (async () => {
  567. const [session, messages, todo, diff] = await Promise.all([
  568. sdk.client.session.get({ sessionID }, { throwOnError: true }),
  569. sdk.client.session.messages({ sessionID, limit: 100 }),
  570. sdk.client.session.todo({ sessionID }),
  571. sdk.client.session.diff({ sessionID }),
  572. ])
  573. setStore(
  574. produce((draft) => {
  575. const match = search(draft.session, sessionID, (s) => s.id)
  576. if (match.found) draft.session[match.index] = session.data!
  577. if (!match.found) draft.session.splice(match.index, 0, session.data!)
  578. draft.todo[sessionID] = todo.data ?? []
  579. const currentMessages = draft.message[sessionID] ?? []
  580. const infos = (messages.data ?? []).flatMap((message) => {
  581. if (!tracker.messages.has(message.info.id)) return [message.info]
  582. const current = currentMessages.find((item) => item.id === message.info.id)
  583. return current ? [current] : []
  584. })
  585. infos.push(
  586. ...currentMessages.filter(
  587. (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
  588. ),
  589. )
  590. const removed = infos.slice(0, -100)
  591. const visible = infos.slice(-100)
  592. const visibleIDs = new Set(visible.map((message) => message.id))
  593. for (const message of messages.data ?? []) {
  594. if (!visibleIDs.has(message.info.id)) {
  595. delete draft.part[message.info.id]
  596. continue
  597. }
  598. const currentParts = draft.part[message.info.id] ?? []
  599. const parts = message.parts.flatMap((part) => {
  600. const current = currentParts.find((item) => item.id === part.id)
  601. if (tracker.parts.has(part.id)) return current ? [current] : []
  602. if (
  603. current &&
  604. (part.type === "text" || part.type === "reasoning") &&
  605. (current.type === "text" || current.type === "reasoning") &&
  606. part.text.length === 0 &&
  607. current.text.length > 0
  608. ) {
  609. return [current]
  610. }
  611. return [part]
  612. })
  613. parts.push(
  614. ...currentParts.filter(
  615. (part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
  616. ),
  617. )
  618. draft.part[message.info.id] = parts
  619. }
  620. for (const message of removed) delete draft.part[message.id]
  621. draft.message[sessionID] = visible
  622. draft.session_diff[sessionID] = diff.data ?? []
  623. }),
  624. )
  625. fullSyncedSessions.add(sessionID)
  626. })().finally(() => {
  627. syncingSessions.delete(sessionID)
  628. hydratingSessions.delete(sessionID)
  629. })
  630. syncingSessions.set(sessionID, task)
  631. return task
  632. },
  633. },
  634. bootstrap,
  635. }
  636. return result
  637. },
  638. })