session-context-tab.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
  2. import type { JSX } from "solid-js"
  3. import { useSync } from "@/context/sync"
  4. import { checksum } from "@opencode-ai/util/encode"
  5. import { findLast } from "@opencode-ai/util/array"
  6. import { same } from "@/utils/same"
  7. import { Icon } from "@opencode-ai/ui/icon"
  8. import { Accordion } from "@opencode-ai/ui/accordion"
  9. import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
  10. import { File } from "@opencode-ai/ui/file"
  11. import { Markdown } from "@opencode-ai/ui/markdown"
  12. import { ScrollView } from "@opencode-ai/ui/scroll-view"
  13. import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
  14. import { useLanguage } from "@/context/language"
  15. import { useProviders } from "@/hooks/use-providers"
  16. import { useSessionLayout } from "@/pages/session/session-layout"
  17. import { getSessionContextMetrics } from "./session-context-metrics"
  18. import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
  19. import { createSessionContextFormatter } from "./session-context-format"
  20. const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
  21. system: "var(--syntax-info)",
  22. user: "var(--syntax-success)",
  23. assistant: "var(--syntax-property)",
  24. tool: "var(--syntax-warning)",
  25. other: "var(--syntax-comment)",
  26. }
  27. function Stat(props: { label: string; value: JSX.Element }) {
  28. return (
  29. <div class="flex flex-col gap-1">
  30. <div class="text-12-regular text-text-weak">{props.label}</div>
  31. <div class="text-12-medium text-text-strong">{props.value}</div>
  32. </div>
  33. )
  34. }
  35. function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
  36. const file = createMemo(() => {
  37. const parts = props.getParts(props.message.id)
  38. const contents = JSON.stringify({ message: props.message, parts }, null, 2)
  39. return {
  40. name: `${props.message.role}-${props.message.id}.json`,
  41. contents,
  42. cacheKey: checksum(contents),
  43. }
  44. })
  45. return (
  46. <File
  47. mode="text"
  48. file={file()}
  49. overflow="wrap"
  50. class="select-text"
  51. onRendered={() => requestAnimationFrame(props.onRendered)}
  52. />
  53. )
  54. }
  55. function RawMessage(props: {
  56. message: Message
  57. getParts: (id: string) => Part[]
  58. onRendered: () => void
  59. time: (value: number | undefined) => string
  60. }) {
  61. return (
  62. <Accordion.Item value={props.message.id}>
  63. <StickyAccordionHeader>
  64. <Accordion.Trigger>
  65. <div class="flex items-center justify-between gap-2 w-full">
  66. <div class="min-w-0 truncate">
  67. {props.message.role} <span class="text-text-base">• {props.message.id}</span>
  68. </div>
  69. <div class="flex items-center gap-3">
  70. <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
  71. <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
  72. </div>
  73. </div>
  74. </Accordion.Trigger>
  75. </StickyAccordionHeader>
  76. <Accordion.Content class="bg-background-base">
  77. <div class="p-3">
  78. <RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
  79. </div>
  80. </Accordion.Content>
  81. </Accordion.Item>
  82. )
  83. }
  84. const emptyMessages: Message[] = []
  85. const emptyUserMessages: UserMessage[] = []
  86. export function SessionContextTab() {
  87. const sync = useSync()
  88. const language = useLanguage()
  89. const providers = useProviders()
  90. const { params, view } = useSessionLayout()
  91. const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
  92. const messages = createMemo(
  93. () => {
  94. const id = params.id
  95. if (!id) return emptyMessages
  96. return (sync.data.message[id] ?? []) as Message[]
  97. },
  98. emptyMessages,
  99. { equals: same },
  100. )
  101. const userMessages = createMemo(
  102. () => messages().filter((m) => m.role === "user") as UserMessage[],
  103. emptyUserMessages,
  104. { equals: same },
  105. )
  106. const visibleUserMessages = createMemo(
  107. () => {
  108. const revert = info()?.revert?.messageID
  109. if (!revert) return userMessages()
  110. return userMessages().filter((m) => m.id < revert)
  111. },
  112. emptyUserMessages,
  113. { equals: same },
  114. )
  115. const usd = createMemo(
  116. () =>
  117. new Intl.NumberFormat(language.intl(), {
  118. style: "currency",
  119. currency: "USD",
  120. }),
  121. )
  122. const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
  123. const ctx = createMemo(() => metrics().context)
  124. const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
  125. const cost = createMemo(() => {
  126. return usd().format(metrics().totalCost)
  127. })
  128. const counts = createMemo(() => {
  129. const all = messages()
  130. const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
  131. const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
  132. return {
  133. all: all.length,
  134. user,
  135. assistant,
  136. }
  137. })
  138. const systemPrompt = createMemo(() => {
  139. const msg = findLast(visibleUserMessages(), (m) => !!m.system)
  140. const system = msg?.system
  141. if (!system) return
  142. const trimmed = system.trim()
  143. if (!trimmed) return
  144. return trimmed
  145. })
  146. const providerLabel = createMemo(() => {
  147. const c = ctx()
  148. if (!c) return "—"
  149. return c.providerLabel
  150. })
  151. const modelLabel = createMemo(() => {
  152. const c = ctx()
  153. if (!c) return "—"
  154. return c.modelLabel
  155. })
  156. const breakdown = createMemo(
  157. on(
  158. () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
  159. () => {
  160. const c = ctx()
  161. if (!c?.input) return []
  162. return estimateSessionContextBreakdown({
  163. messages: messages(),
  164. parts: sync.data.part as Record<string, Part[] | undefined>,
  165. input: c.input,
  166. systemPrompt: systemPrompt(),
  167. })
  168. },
  169. ),
  170. )
  171. const breakdownLabel = (key: SessionContextBreakdownKey) => {
  172. if (key === "system") return language.t("context.breakdown.system")
  173. if (key === "user") return language.t("context.breakdown.user")
  174. if (key === "assistant") return language.t("context.breakdown.assistant")
  175. if (key === "tool") return language.t("context.breakdown.tool")
  176. return language.t("context.breakdown.other")
  177. }
  178. const stats = [
  179. { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
  180. { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
  181. { label: "context.stats.provider", value: providerLabel },
  182. { label: "context.stats.model", value: modelLabel },
  183. { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
  184. { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
  185. { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
  186. { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
  187. { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
  188. { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
  189. {
  190. label: "context.stats.cacheTokens",
  191. value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
  192. },
  193. { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
  194. { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
  195. { label: "context.stats.totalCost", value: cost },
  196. { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
  197. { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
  198. ] satisfies { label: string; value: () => JSX.Element }[]
  199. let scroll: HTMLDivElement | undefined
  200. let frame: number | undefined
  201. let pending: { x: number; y: number } | undefined
  202. const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
  203. const restoreScroll = () => {
  204. const el = scroll
  205. if (!el) return
  206. const s = view().scroll("context")
  207. if (!s) return
  208. if (el.scrollTop !== s.y) el.scrollTop = s.y
  209. if (el.scrollLeft !== s.x) el.scrollLeft = s.x
  210. }
  211. const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
  212. pending = {
  213. x: event.currentTarget.scrollLeft,
  214. y: event.currentTarget.scrollTop,
  215. }
  216. if (frame !== undefined) return
  217. frame = requestAnimationFrame(() => {
  218. frame = undefined
  219. const next = pending
  220. pending = undefined
  221. if (!next) return
  222. view().setScroll("context", next)
  223. })
  224. }
  225. createEffect(
  226. on(
  227. () => messages().length,
  228. () => {
  229. requestAnimationFrame(restoreScroll)
  230. },
  231. { defer: true },
  232. ),
  233. )
  234. onCleanup(() => {
  235. if (frame === undefined) return
  236. cancelAnimationFrame(frame)
  237. })
  238. return (
  239. <ScrollView
  240. class="@container h-full"
  241. viewportRef={(el) => {
  242. scroll = el
  243. restoreScroll()
  244. }}
  245. onScroll={handleScroll}
  246. >
  247. <div class="px-6 pt-4 pb-10 flex flex-col gap-10">
  248. <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
  249. <For each={stats}>
  250. {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
  251. </For>
  252. </div>
  253. <Show when={breakdown().length > 0}>
  254. <div class="flex flex-col gap-2">
  255. <div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
  256. <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
  257. <For each={breakdown()}>
  258. {(segment) => (
  259. <div
  260. class="h-full"
  261. style={{
  262. width: `${segment.width}%`,
  263. "background-color": BREAKDOWN_COLOR[segment.key],
  264. }}
  265. />
  266. )}
  267. </For>
  268. </div>
  269. <div class="flex flex-wrap gap-x-3 gap-y-1">
  270. <For each={breakdown()}>
  271. {(segment) => (
  272. <div class="flex items-center gap-1 text-11-regular text-text-weak">
  273. <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
  274. <div>{breakdownLabel(segment.key)}</div>
  275. <div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
  276. </div>
  277. )}
  278. </For>
  279. </div>
  280. <div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
  281. </div>
  282. </Show>
  283. <Show when={systemPrompt()}>
  284. {(prompt) => (
  285. <div class="flex flex-col gap-2">
  286. <div class="text-12-regular text-text-weak">{language.t("context.systemPrompt.title")}</div>
  287. <div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
  288. <Markdown text={prompt()} class="text-12-regular" />
  289. </div>
  290. </div>
  291. )}
  292. </Show>
  293. <div class="flex flex-col gap-2">
  294. <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
  295. <Accordion multiple>
  296. <For each={messages()}>
  297. {(message) => (
  298. <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
  299. )}
  300. </For>
  301. </Accordion>
  302. </div>
  303. </div>
  304. </ScrollView>
  305. )
  306. }