Download packages/app/src/components/session/session-context-tab.tsx from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/components/session/session-context-tab.tsx
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/components/session/session-context-tab.tsx
-
curl -L -o session-context-tab.tsx https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/components/session/session-context-tab.tsx
13.6 kB
| 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/core/util/encode" | |
| import { findLast } from "@opencode-ai/core/util/array" | |
| import { same } from "@/utils/same" | |
| import { Icon } from "@opencode-ai/ui/icon" | |
| import { Button } from "@opencode-ai/ui/button" | |
| import { Accordion } from "@opencode-ai/ui/accordion" | |
| import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" | |
| import { File } from "@opencode-ai/session-ui/file" | |
| import { Markdown } from "@opencode-ai/session-ui/markdown" | |
| import { ScrollView } from "@opencode-ai/ui/scroll-view" | |
| import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" | |
| import { showToast } from "@/utils/toast" | |
| import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" | |
| import { useLanguage } from "@/context/language" | |
| import { useProviders } from "@/hooks/use-providers" | |
| import { useSDK } from "@/context/sdk" | |
| import { useSessionLayout } from "@/pages/session/session-layout" | |
| import { getSessionContext } 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 sdk = useSDK() | |
| const providers = useProviders(() => sdk().directory) | |
| 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() | |
| const boundary = userMessages().findIndex((message) => message.id === revert) | |
| return boundary < 0 ? userMessages() : userMessages().slice(0, boundary) | |
| }, | |
| emptyUserMessages, | |
| { equals: same }, | |
| ) | |
| const usd = createMemo( | |
| () => | |
| new Intl.NumberFormat(language.intl(), { | |
| style: "currency", | |
| currency: "USD", | |
| }), | |
| ) | |
| const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) | |
| const formatter = createMemo(() => createSessionContextFormatter(language.intl())) | |
| const cost = createMemo(() => { | |
| return usd().format(info()?.cost ?? 0) | |
| }) | |
| 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()?.message.tokens.output) }, | |
| { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, | |
| { | |
| label: "context.stats.cacheTokens", | |
| value: () => | |
| `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, | |
| }, | |
| { 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 }[] | |
| const exportSession = async () => { | |
| const sessionID = params.id | |
| if (!sessionID) return | |
| try { | |
| const data = await fetchSessionExport({ | |
| sessionID, | |
| client: sdk().client, | |
| }) | |
| const filename = sessionExportFilename(data.info) | |
| downloadSessionExport(filename, data) | |
| showToast({ | |
| variant: "success", | |
| icon: "circle-check", | |
| title: language.t("toast.session.export.success.title"), | |
| description: language.t("toast.session.export.success.description", { filename }), | |
| }) | |
| } catch (err) { | |
| showToast({ | |
| variant: "error", | |
| title: language.t("toast.session.export.failed.title"), | |
| description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), | |
| }) | |
| } | |
| } | |
| 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="flex items-center justify-between"> | |
| <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> | |
| <Button | |
| size="small" | |
| variant="ghost" | |
| class="gap-1.5 px-2 text-text-weak hover:text-text-base" | |
| onClick={exportSession} | |
| > | |
| <Icon name="download" size="small" /> | |
| <span>{language.t("context.export.session")}</span> | |
| </Button> | |
| </div> | |
| <Accordion multiple> | |
| <For each={messages()}> | |
| {(message) => ( | |
| <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> | |
| )} | |
| </For> | |
| </Accordion> | |
| </div> | |
| </div> | |
| </ScrollView> | |
| ) | |
| } | |