diff --git a/bun.lock b/bun.lock index 7d6907a72..62c29200d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "opencode", diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css index f11e00b21..83c783a2f 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css @@ -56,6 +56,53 @@ color: var(--color-text); font-weight: 500; } + + [data-slot="tokens-with-breakdown"] { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + } + + [data-slot="breakdown-button"] { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: var(--color-text); + } + + svg { + width: 16px; + height: 16px; + } + } + + [data-slot="breakdown-popup"] { + position: absolute; + left: 0; + top: 100%; + margin-top: var(--space-2); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + z-index: 10; + min-width: 180px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-size: var(--font-size-xs); + + @media (prefers-color-scheme: dark) { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + } } tbody tr:last-child td { @@ -116,4 +163,24 @@ } } } + + /* Breakdown popup content */ + [data-slot="breakdown-row"] { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + padding: var(--space-1) 0; + } + + [data-slot="breakdown-label"] { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + + [data-slot="breakdown-value"] { + color: var(--color-text); + font-weight: 500; + font-size: var(--font-size-xs); + } } diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx index d9aa7251b..5c461b89d 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx @@ -1,6 +1,6 @@ import { Billing } from "@opencode-ai/console-core/billing.js" import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, For, Show, createEffect } from "solid-js" +import { createMemo, For, Show, createEffect, createSignal } from "solid-js" import { formatDateUTC, formatDateForTable } from "../common" import { withActor } from "~/context/auth.withActor" import { IconChevronLeft, IconChevronRight } from "~/component/icon" @@ -22,15 +22,34 @@ export function UsageSection() { const params = useParams() const usage = createAsync(() => queryUsageInfo(params.id!, 0)) const [store, setStore] = createStore({ page: 0, usage: [] as Awaited> }) + const [openBreakdownId, setOpenBreakdownId] = createSignal(null) createEffect(() => { setStore({ usage: usage() }) }, [usage]) + createEffect(() => { + if (!openBreakdownId()) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.closest('[data-slot="tokens-with-breakdown"]')) { + setOpenBreakdownId(null) + } + } + + document.addEventListener("click", handleClickOutside) + return () => document.removeEventListener("click", handleClickOutside) + }) + const hasResults = createMemo(() => store.usage && store.usage.length > 0) const canGoPrev = createMemo(() => store.page > 0) const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE) + const calculateTotalInputTokens = (u: Awaited>[0]) => { + return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0) + } + const goPrev = async () => { const usage = await getUsageInfo(params.id!, store.page - 1) setStore({ @@ -73,15 +92,53 @@ export function UsageSection() { - {(usage) => { + {(usage, index) => { const date = createMemo(() => new Date(usage.timeCreated)) + const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage)) + const breakdownId = `breakdown-${index()}` + const isOpen = createMemo(() => openBreakdownId() === breakdownId) + const isClaude = usage.model.toLowerCase().includes("claude") return ( {formatDateForTable(date())} {usage.model} - {usage.inputTokens} + +
e.stopPropagation()}> + + setOpenBreakdownId(null)}>{totalInputTokens()} + +
e.stopPropagation()}> +
+ Input + {usage.inputTokens} +
+
+ Cache Read + {usage.cacheReadTokens ?? 0} +
+ +
+ Cache Write + {usage.cacheWrite5mTokens ?? 0} +
+
+
+
+
+ {usage.outputTokens} ${((usage.cost ?? 0) / 100000000).toFixed(4)}