session.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { createStore, produce } from "solid-js/store"
  2. import { createSimpleContext } from "@opencode-ai/ui/context"
  3. import { batch, createEffect, createMemo } from "solid-js"
  4. import { useSync } from "./sync"
  5. import { makePersisted } from "@solid-primitives/storage"
  6. import { TextSelection } from "./local"
  7. import { pipe, sumBy } from "remeda"
  8. import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
  9. import { useParams } from "@solidjs/router"
  10. import { base64Encode } from "@/utils"
  11. export const { use: useSession, provider: SessionProvider } = createSimpleContext({
  12. name: "Session",
  13. init: () => {
  14. const params = useParams()
  15. const sync = useSync()
  16. const name = createMemo(
  17. () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
  18. )
  19. const [store, setStore] = makePersisted(
  20. createStore<{
  21. messageId?: string
  22. tabs: {
  23. active?: string
  24. opened: string[]
  25. }
  26. prompt: Prompt
  27. cursor?: number
  28. }>({
  29. tabs: {
  30. opened: [],
  31. },
  32. prompt: clonePrompt(DEFAULT_PROMPT),
  33. cursor: undefined,
  34. }),
  35. {
  36. name: name(),
  37. },
  38. )
  39. createEffect(() => {
  40. if (!params.id) return
  41. sync.session.sync(params.id)
  42. })
  43. const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
  44. const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
  45. const userMessages = createMemo(() =>
  46. messages()
  47. .filter((m) => m.role === "user")
  48. .sort((a, b) => b.id.localeCompare(a.id)),
  49. )
  50. const lastUserMessage = createMemo(() => {
  51. return userMessages()?.at(0)
  52. })
  53. const activeMessage = createMemo(() => {
  54. if (!store.messageId) return lastUserMessage()
  55. return userMessages()?.find((m) => m.id === store.messageId)
  56. })
  57. const status = createMemo(
  58. () =>
  59. sync.data.session_status[params.id ?? ""] ?? {
  60. type: "idle",
  61. },
  62. )
  63. const working = createMemo(() => status()?.type !== "idle")
  64. const cost = createMemo(() => {
  65. const total = pipe(
  66. messages(),
  67. sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
  68. )
  69. return new Intl.NumberFormat("en-US", {
  70. style: "currency",
  71. currency: "USD",
  72. }).format(total)
  73. })
  74. const last = createMemo(
  75. () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
  76. )
  77. const model = createMemo(() =>
  78. last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
  79. )
  80. const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
  81. const tokens = createMemo(() => {
  82. if (!last()) return
  83. const tokens = last().tokens
  84. return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
  85. })
  86. const context = createMemo(() => {
  87. const total = tokens()
  88. const limit = model()?.limit.context
  89. if (!total || !limit) return 0
  90. return Math.round((total / limit) * 100)
  91. })
  92. return {
  93. get id() {
  94. return params.id
  95. },
  96. info,
  97. status,
  98. working,
  99. diffs,
  100. prompt: {
  101. current: createMemo(() => store.prompt),
  102. cursor: createMemo(() => store.cursor),
  103. dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
  104. set(prompt: Prompt, cursorPosition?: number) {
  105. const next = clonePrompt(prompt)
  106. batch(() => {
  107. setStore("prompt", next)
  108. if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
  109. })
  110. },
  111. },
  112. messages: {
  113. all: messages,
  114. user: userMessages,
  115. last: lastUserMessage,
  116. active: activeMessage,
  117. setActive(message: UserMessage | undefined) {
  118. setStore("messageId", message?.id)
  119. },
  120. },
  121. usage: {
  122. tokens,
  123. cost,
  124. context,
  125. },
  126. layout: {
  127. tabs: store.tabs,
  128. setActiveTab(tab: string | undefined) {
  129. setStore("tabs", "active", tab)
  130. },
  131. setOpenedTabs(tabs: string[]) {
  132. setStore("tabs", "opened", tabs)
  133. },
  134. async openTab(tab: string) {
  135. if (tab === "chat") {
  136. setStore("tabs", "active", undefined)
  137. return
  138. }
  139. if (tab !== "review") {
  140. if (!store.tabs.opened.includes(tab)) {
  141. setStore("tabs", "opened", [...store.tabs.opened, tab])
  142. }
  143. }
  144. setStore("tabs", "active", tab)
  145. },
  146. closeTab(tab: string) {
  147. batch(() => {
  148. setStore(
  149. "tabs",
  150. "opened",
  151. store.tabs.opened.filter((x) => x !== tab),
  152. )
  153. if (store.tabs.active === tab) {
  154. const index = store.tabs.opened.findIndex((f) => f === tab)
  155. const previous = store.tabs.opened[Math.max(0, index - 1)]
  156. setStore("tabs", "active", previous)
  157. }
  158. })
  159. },
  160. moveTab(tab: string, to: number) {
  161. const index = store.tabs.opened.findIndex((f) => f === tab)
  162. if (index === -1) return
  163. setStore(
  164. "tabs",
  165. "opened",
  166. produce((opened) => {
  167. opened.splice(to, 0, opened.splice(index, 1)[0])
  168. }),
  169. )
  170. },
  171. },
  172. }
  173. },
  174. })
  175. interface PartBase {
  176. content: string
  177. start: number
  178. end: number
  179. }
  180. export interface TextPart extends PartBase {
  181. type: "text"
  182. }
  183. export interface FileAttachmentPart extends PartBase {
  184. type: "file"
  185. path: string
  186. selection?: TextSelection
  187. }
  188. export type ContentPart = TextPart | FileAttachmentPart
  189. export type Prompt = ContentPart[]
  190. export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
  191. export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
  192. if (promptA.length !== promptB.length) return false
  193. for (let i = 0; i < promptA.length; i++) {
  194. const partA = promptA[i]
  195. const partB = promptB[i]
  196. if (partA.type !== partB.type) return false
  197. if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
  198. return false
  199. }
  200. if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
  201. return false
  202. }
  203. }
  204. return true
  205. }
  206. function cloneSelection(selection?: TextSelection) {
  207. if (!selection) return undefined
  208. return { ...selection }
  209. }
  210. function clonePart(part: ContentPart): ContentPart {
  211. if (part.type === "text") return { ...part }
  212. return {
  213. ...part,
  214. selection: cloneSelection(part.selection),
  215. }
  216. }
  217. function clonePrompt(prompt: Prompt): Prompt {
  218. return prompt.map(clonePart)
  219. }