+17





![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)
![opencode-agent[bot]](/assets/img/avatar_default.png)
opencode-agent[bot]
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
LukeParkerDev
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aiden Cline
Brendan Allan
Aarav Sareen
Julian Coy
Brendan Allan
usrnk1
opencode
Vladimir Glafirov
Adam
Frank
Jay
Dustin Deus
Kit Langton
James Long
Simon Klee
Jay
Jack
David Hill
Aiden Cline
James Long
冯基魁
Aiden Cline
Victor Navarro
43ecf3ff1b
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com>
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
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 { 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 { 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()
|
|
return userMessages().filter((m) => m.id < revert)
|
|
},
|
|
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 }[]
|
|
|
|
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>
|
|
)
|
|
}
|