| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
- import type { JSX } from "solid-js"
- import { useSync } from "@/context/sync"
- import { checksum } from "@opencode-ai/util/encode"
- import { findLast } from "@opencode-ai/util/array"
- import { same } from "@/utils/same"
- import { Icon } from "@opencode-ai/ui/icon"
- import { Accordion } from "@opencode-ai/ui/accordion"
- import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
- import { File } from "@opencode-ai/ui/file"
- import { Markdown } from "@opencode-ai/ui/markdown"
- import { ScrollView } from "@opencode-ai/ui/scroll-view"
- import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
- import { useLanguage } from "@/context/language"
- import { useProviders } from "@/hooks/use-providers"
- import { useSessionLayout } from "@/pages/session/session-layout"
- import { getSessionContextMetrics } from "./session-context-metrics"
- import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
- import { createSessionContextFormatter } from "./session-context-format"
- const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
- system: "var(--syntax-info)",
- user: "var(--syntax-success)",
- assistant: "var(--syntax-property)",
- tool: "var(--syntax-warning)",
- other: "var(--syntax-comment)",
- }
- function Stat(props: { label: string; value: JSX.Element }) {
- return (
- <div class="flex flex-col gap-1">
- <div class="text-12-regular text-text-weak">{props.label}</div>
- <div class="text-12-medium text-text-strong">{props.value}</div>
- </div>
- )
- }
- function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
- const file = createMemo(() => {
- const parts = props.getParts(props.message.id)
- const contents = JSON.stringify({ message: props.message, parts }, null, 2)
- return {
- name: `${props.message.role}-${props.message.id}.json`,
- contents,
- cacheKey: checksum(contents),
- }
- })
- return (
- <File
- mode="text"
- file={file()}
- overflow="wrap"
- class="select-text"
- onRendered={() => requestAnimationFrame(props.onRendered)}
- />
- )
- }
- function RawMessage(props: {
- message: Message
- getParts: (id: string) => Part[]
- onRendered: () => void
- time: (value: number | undefined) => string
- }) {
- return (
- <Accordion.Item value={props.message.id}>
- <StickyAccordionHeader>
- <Accordion.Trigger>
- <div class="flex items-center justify-between gap-2 w-full">
- <div class="min-w-0 truncate">
- {props.message.role} <span class="text-text-base">• {props.message.id}</span>
- </div>
- <div class="flex items-center gap-3">
- <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
- <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
- </div>
- </div>
- </Accordion.Trigger>
- </StickyAccordionHeader>
- <Accordion.Content class="bg-background-base">
- <div class="p-3">
- <RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
- </div>
- </Accordion.Content>
- </Accordion.Item>
- )
- }
- const emptyMessages: Message[] = []
- const emptyUserMessages: UserMessage[] = []
- export function SessionContextTab() {
- const sync = useSync()
- const language = useLanguage()
- const providers = useProviders()
- const { params, view } = useSessionLayout()
- const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
- const messages = createMemo(
- () => {
- const id = params.id
- if (!id) return emptyMessages
- return (sync.data.message[id] ?? []) as Message[]
- },
- emptyMessages,
- { equals: same },
- )
- const userMessages = createMemo(
- () => messages().filter((m) => m.role === "user") as UserMessage[],
- emptyUserMessages,
- { equals: same },
- )
- const visibleUserMessages = createMemo(
- () => {
- const revert = info()?.revert?.messageID
- if (!revert) return userMessages()
- return userMessages().filter((m) => m.id < revert)
- },
- emptyUserMessages,
- { equals: same },
- )
- const usd = createMemo(
- () =>
- new Intl.NumberFormat(language.intl(), {
- style: "currency",
- currency: "USD",
- }),
- )
- const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
- const ctx = createMemo(() => metrics().context)
- const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
- const cost = createMemo(() => {
- return usd().format(metrics().totalCost)
- })
- const counts = createMemo(() => {
- const all = messages()
- const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
- const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
- return {
- all: all.length,
- user,
- assistant,
- }
- })
- const systemPrompt = createMemo(() => {
- const msg = findLast(visibleUserMessages(), (m) => !!m.system)
- const system = msg?.system
- if (!system) return
- const trimmed = system.trim()
- if (!trimmed) return
- return trimmed
- })
- const providerLabel = createMemo(() => {
- const c = ctx()
- if (!c) return "—"
- return c.providerLabel
- })
- const modelLabel = createMemo(() => {
- const c = ctx()
- if (!c) return "—"
- return c.modelLabel
- })
- const breakdown = createMemo(
- on(
- () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
- () => {
- const c = ctx()
- if (!c?.input) return []
- return estimateSessionContextBreakdown({
- messages: messages(),
- parts: sync.data.part as Record<string, Part[] | undefined>,
- input: c.input,
- systemPrompt: systemPrompt(),
- })
- },
- ),
- )
- const breakdownLabel = (key: SessionContextBreakdownKey) => {
- if (key === "system") return language.t("context.breakdown.system")
- if (key === "user") return language.t("context.breakdown.user")
- if (key === "assistant") return language.t("context.breakdown.assistant")
- if (key === "tool") return language.t("context.breakdown.tool")
- return language.t("context.breakdown.other")
- }
- const stats = [
- { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
- { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
- { label: "context.stats.provider", value: providerLabel },
- { label: "context.stats.model", value: modelLabel },
- { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
- { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
- { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
- { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
- { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
- { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
- {
- label: "context.stats.cacheTokens",
- value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
- },
- { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
- { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
- { label: "context.stats.totalCost", value: cost },
- { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
- { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
- ] satisfies { label: string; value: () => JSX.Element }[]
- let scroll: HTMLDivElement | undefined
- let frame: number | undefined
- let pending: { x: number; y: number } | undefined
- const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
- const restoreScroll = () => {
- const el = scroll
- if (!el) return
- const s = view().scroll("context")
- if (!s) return
- if (el.scrollTop !== s.y) el.scrollTop = s.y
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- }
- if (frame !== undefined) return
- frame = requestAnimationFrame(() => {
- frame = undefined
- const next = pending
- pending = undefined
- if (!next) return
- view().setScroll("context", next)
- })
- }
- createEffect(
- on(
- () => messages().length,
- () => {
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
- onCleanup(() => {
- if (frame === undefined) return
- cancelAnimationFrame(frame)
- })
- return (
- <ScrollView
- class="@container h-full"
- viewportRef={(el) => {
- scroll = el
- restoreScroll()
- }}
- onScroll={handleScroll}
- >
- <div class="px-6 pt-4 pb-10 flex flex-col gap-10">
- <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
- <For each={stats}>
- {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
- </For>
- </div>
- <Show when={breakdown().length > 0}>
- <div class="flex flex-col gap-2">
- <div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
- <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
- <For each={breakdown()}>
- {(segment) => (
- <div
- class="h-full"
- style={{
- width: `${segment.width}%`,
- "background-color": BREAKDOWN_COLOR[segment.key],
- }}
- />
- )}
- </For>
- </div>
- <div class="flex flex-wrap gap-x-3 gap-y-1">
- <For each={breakdown()}>
- {(segment) => (
- <div class="flex items-center gap-1 text-11-regular text-text-weak">
- <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
- <div>{breakdownLabel(segment.key)}</div>
- <div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
- </div>
- )}
- </For>
- </div>
- <div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
- </div>
- </Show>
- <Show when={systemPrompt()}>
- {(prompt) => (
- <div class="flex flex-col gap-2">
- <div class="text-12-regular text-text-weak">{language.t("context.systemPrompt.title")}</div>
- <div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
- <Markdown text={prompt()} class="text-12-regular" />
- </div>
- </div>
- )}
- </Show>
- <div class="flex flex-col gap-2">
- <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
- <Accordion multiple>
- <For each={messages()}>
- {(message) => (
- <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
- )}
- </For>
- </Accordion>
- </div>
- </div>
- </ScrollView>
- )
- }
|