session-context-tab.tsx 12 KB

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