From 227b354cf5ce0f937b169b03bcbac4ffbb3e8ca6 Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:53:52 +0800 Subject: [PATCH] feat(chat): show persistent session token usage Add a responsive token summary to the composer status row and reuse the existing detailed usage popover. Keep usage scoped to the runtime conversation, hide unavailable counters, and share one subscription across both indicators. Ensure the status row has a real size-container ancestor so its compact and detailed layouts respond to panel width. Closes #748 --- .../chat/composer-context-usage.test.tsx | 179 +++++++++- .../chat/composer-context-usage.tsx | 322 +++++++++++++----- src/components/chat/message-input.test.tsx | 43 ++- src/components/chat/message-input.tsx | 53 +-- .../conversation-detail-panel-layout.test.ts | 17 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 15 files changed, 500 insertions(+), 124 deletions(-) diff --git a/src/components/chat/composer-context-usage.test.tsx b/src/components/chat/composer-context-usage.test.tsx index 6c3893c526..fe7d7d54d6 100644 --- a/src/components/chat/composer-context-usage.test.tsx +++ b/src/components/chat/composer-context-usage.test.tsx @@ -20,7 +20,11 @@ vi.mock("@/stores/conversation-runtime-store", () => ({ useConversationRuntimeStore: vi.fn(), })) -import { ComposerContextUsage } from "./composer-context-usage" +import { + ComposerContextUsage, + ComposerTokenSummary, + ComposerUsageIndicators, +} from "./composer-context-usage" import { useTabStore } from "@/contexts/tab-context" import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store" @@ -160,6 +164,179 @@ function renderStats(stats: SessionStats | null) { ) } +function renderSummary( + stats: SessionStats | null, + options: { + conversationId?: number + runtimeConversationId?: number + runtimeStats?: SessionStats | null + } = {} +) { + const conversationId = options.conversationId ?? 7 + const tabs: TabSlice = { + tabs: [ + { + id: "tab-1", + kind: "conversation", + conversationId, + runtimeConversationId: options.runtimeConversationId, + }, + ], + } + const byConversationId = new Map([[conversationId, { sessionStats: stats }]]) + if (options.runtimeConversationId != null) { + byConversationId.set(options.runtimeConversationId, { + sessionStats: options.runtimeStats ?? null, + }) + } + const runtime: RuntimeSlice = { byConversationId } + mockTabs.mockImplementation((sel: (s: TabSlice) => unknown) => sel(tabs)) + mockRuntime.mockImplementation((sel: (s: RuntimeSlice) => unknown) => + sel(runtime) + ) + return render( + + + + ) +} + +describe("ComposerTokenSummary", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("keeps total, input, output, cache read, and cache hit visible", async () => { + renderSummary({ + total_usage: usage({ + input_tokens: 1_000, + output_tokens: 500, + cache_creation_input_tokens: 1_000, + cache_read_input_tokens: 8_000, + }), + total_tokens: 10_500, + total_duration_ms: 0, + } as SessionStats) + + const summary = screen.getByRole("button", { + name: `${copy.tokenUsage}: 10.5K`, + }) + expect(summary).toHaveTextContent(`10.5K ${copy.tokenUnitShort}`) + expect(summary).toHaveTextContent(`${copy.input} 1K`) + expect(summary).toHaveTextContent(`${copy.output} 500`) + expect(summary).toHaveTextContent(`${copy.cacheRead} 8K`) + expect(summary).toHaveTextContent(`${copy.cacheHit} 80.0%`) + expect(summary.innerHTML).toContain("@[40rem]:hidden") + expect(summary.innerHTML).toContain("@[40rem]:inline-flex") + + await userEvent.click(summary) + expect(valueFor(copy.cacheWrite)).toBe("1K") + expect(valueFor(copy.total)).toBe("10.5K") + }) + + it("omits counters that the agent did not report instead of showing zero", async () => { + renderSummary({ + total_usage: usage({ input_tokens: 2_803, output_tokens: 19 }), + total_tokens: 2_822, + total_duration_ms: 0, + } as SessionStats) + + const summary = screen.getByRole("button", { + name: `${copy.tokenUsage}: 2.8K`, + }) + expect(summary).toHaveTextContent(`${copy.input} 2.8K`) + expect(summary).toHaveTextContent(`${copy.output} 19`) + expect(summary).not.toHaveTextContent(copy.cacheRead) + expect(summary).not.toHaveTextContent(copy.cacheHit) + + await userEvent.click(summary) + expect(screen.queryByText(copy.cacheRead)).not.toBeInTheDocument() + expect(screen.queryByText(copy.cacheWrite)).not.toBeInTheDocument() + expect(screen.queryByText(copy.cacheHit)).not.toBeInTheDocument() + }) + + it("uses the tab's runtime conversation without leaking stale persisted stats", () => { + renderSummary( + { + total_usage: usage({ input_tokens: 99_000 }), + total_tokens: 99_000, + total_duration_ms: 0, + } as SessionStats, + { + runtimeConversationId: 11, + runtimeStats: { + total_usage: usage({ input_tokens: 1_200, output_tokens: 34 }), + total_tokens: 1_234, + total_duration_ms: 0, + } as SessionStats, + } + ) + + const summary = screen.getByRole("button", { + name: `${copy.tokenUsage}: 1.2K`, + }) + expect(summary).toHaveTextContent(`${copy.input} 1.2K`) + expect(summary).not.toHaveTextContent("99K") + }) + + it("renders nothing when token usage is unavailable", () => { + renderSummary({ + total_usage: usage(), + total_tokens: 0, + total_duration_ms: 0, + } as SessionStats) + + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) +}) + +describe("ComposerUsageIndicators", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shares one conversation subscription across both status indicators", () => { + const tabs: TabSlice = { + tabs: [{ id: "tab-1", kind: "conversation", conversationId: 7 }], + } + const runtime: RuntimeSlice = { + byConversationId: new Map([ + [ + 7, + { + sessionStats: { + total_usage: usage({ input_tokens: 1_000, output_tokens: 20 }), + total_tokens: 1_020, + total_duration_ms: 0, + } as SessionStats, + }, + ], + ]), + } + mockTabs.mockImplementation((sel: (s: TabSlice) => unknown) => sel(tabs)) + mockRuntime.mockImplementation((sel: (s: RuntimeSlice) => unknown) => + sel(runtime) + ) + + render( + + + {({ context, summary }) => ( + <> + {summary} + {context} + + )} + + + ) + + expect(screen.getAllByRole("button")).toHaveLength(2) + expect(mockTabs).toHaveBeenCalledTimes(1) + expect(mockRuntime).toHaveBeenCalledTimes(1) + }) +}) + describe("ComposerContextUsage zeroed counters", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/components/chat/composer-context-usage.tsx b/src/components/chat/composer-context-usage.tsx index d34171b994..5d09f67328 100644 --- a/src/components/chat/composer-context-usage.tsx +++ b/src/components/chat/composer-context-usage.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useSyncExternalStore } from "react" +import { useCallback, useSyncExternalStore, type ReactNode } from "react" import { Coins } from "lucide-react" import { useTranslations } from "next-intl" import { useConnectionStore } from "@/contexts/acp-connections-context" @@ -24,6 +24,11 @@ const ICON_CENTER = 8 const ICON_VIEWBOX = 16 const ICON_CIRCUMFERENCE = 2 * Math.PI * ICON_RADIUS +type TokenRow = { + key: "input" | "output" | "cacheRead" | "cacheWrite" | "total" + value: number +} + /** * Context-window usage circle (+ token breakdown popover) shown in the row below * the composer. Scoped to its own conversation via `tabId`: the live context @@ -31,8 +36,7 @@ const ICON_CIRCUMFERENCE = 2 * Math.PI * ICON_RADIUS * token breakdown from that conversation's own runtime-store session stats — so * every loaded/tiled composer shows its own context, not the active one's. */ -export function ComposerContextUsage({ tabId }: { tabId: string | null }) { - const t = useTranslations("Folder.statusBar.tokens") +function useComposerUsage(tabId: string | null) { const store = useConnectionStore() // This tab's own conversation → its per-conversation session stats, read // straight from the runtime store where every panel already keeps them (keyed @@ -120,17 +124,26 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { const dashOffset = ICON_CIRCUMFERENCE * (1 - (contextPercent ?? 0) / 100) - const rows: { - key: "input" | "output" | "cacheRead" | "cacheWrite" | "total" - value: number - }[] = [] + // Cache counters arrive as one capability: when both are zero, providers + // that omit cache accounting are indistinguishable from an idle cache. Once + // either counter is positive, the other zero is meaningful (for example, a + // first-turn cache write with no read hit yet). + const hasCacheActivity = + hasUsage && + usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0 + + const rows: TokenRow[] = [] if (hasUsage) { rows.push( { key: "input", value: usage.input_tokens }, - { key: "output", value: usage.output_tokens }, - { key: "cacheRead", value: usage.cache_read_input_tokens }, - { key: "cacheWrite", value: usage.cache_creation_input_tokens } + { key: "output", value: usage.output_tokens } ) + if (hasCacheActivity) { + rows.push( + { key: "cacheRead", value: usage.cache_read_input_tokens }, + { key: "cacheWrite", value: usage.cache_creation_input_tokens } + ) + } } if (total != null) { rows.push({ key: "total", value: total }) @@ -148,9 +161,6 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { // Rendering a confident `0.0%` for the latter is worse than rendering // nothing. A session with writes but no reads yet is genuinely 0% and still // shows. - const hasCacheActivity = - hasUsage && - usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0 const cacheHit = hasCacheActivity ? cacheHitRatio( usage.input_tokens, @@ -159,10 +169,138 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { ) : null + return { + cacheHit, + contextMax, + contextPercent, + contextUsed, + dashOffset, + hasContext, + hasTokenSection, + hasUsage, + rows, + total, + } +} + +type ComposerUsage = ReturnType + +function TokenUsagePopoverContent({ + align, + usage, +}: { + align: "center" | "end" + usage: ComposerUsage +}) { + const t = useTranslations("Folder.statusBar.tokens") + const { + cacheHit, + contextMax, + contextPercent, + contextUsed, + hasContext, + hasTokenSection, + hasUsage, + rows, + } = usage + + return ( + + {hasContext || cacheHit != null ? ( +
+ {hasContext ? ( + <> +
+ {t("contextWindow")} + + {formatContextWindowPercent(contextPercent)} + +
+
+
+
+ {/* Dropped entirely rather than shown as "--": an agent can + state its occupancy as a percentage without ever naming the + two token counts behind it (qoder does exactly that once it + has redacted them), and a labelled row with nothing in it + reads as a figure that failed to load rather than one that + was never reported. */} + {contextUsed != null && contextMax != null ? ( +
+ {t("usedMax")} + + {`${formatTokenCount(contextUsed)} / ${formatTokenCount(contextMax)}`} + +
+ ) : null} + + ) : null} + {/* Sits with the context figures rather than under the token + breakdown: it is a ratio, not a token count. */} + {cacheHit != null ? ( +
+ {t("cacheHit")} + + {formatPercent(cacheHit, CACHE_HIT_RATE_DIGITS)} + +
+ ) : null} +
+ ) : null} + {hasTokenSection ? ( + <> +
+ {t("tokenUsage")} +
+
+ {rows.map((row) => ( +
+ {t(row.key)} + + {formatTokenCount(row.value)} + +
+ ))} +
+ + ) : null} + + ) +} + +/** + * Compact context-window indicator at the trailing edge of the composer row. + * The new persistent token summary intentionally does not replace this: the + * context ring and connection state remain stable, familiar controls. + */ +function ComposerContextUsageView({ usage }: { usage: ComposerUsage }) { + const t = useTranslations("Folder.statusBar.tokens") + const { + contextMax, + contextPercent, + contextUsed, + dashOffset, + hasContext, + hasTokenSection, + total, + } = usage + if (!hasContext && !hasTokenSection) return null - // Native hover hint mirroring the popover's headline (the popover stays for - // the full breakdown on click). const triggerTitle = hasContext ? contextUsed != null && contextMax != null ? `${t("contextWindow")}: ${formatContextWindowPercent(contextPercent)} (${formatTokenCount(contextUsed)} / ${formatTokenCount(contextMax)})` @@ -174,7 +312,7 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { - - {hasContext || cacheHit != null ? ( -
- {hasContext ? ( + + + ) +} + +/** + * Persistent, conversation-scoped token summary centered below the composer. + * Container queries keep the row informative in a wide panel and reduce it to + * a single no-wrap headline in a narrow/tiled panel without moving either edge. + */ +function ComposerTokenSummaryView({ usage }: { usage: ComposerUsage }) { + const t = useTranslations("Folder.statusBar.tokens") + const { cacheHit, hasTokenSection, rows, total } = usage + + if (!hasTokenSection || total == null) return null + + const detailRows = rows.filter( + (row) => + row.value > 0 && + (row.key === "input" || row.key === "output" || row.key === "cacheRead") + ) + const label = `${t("tokenUsage")}: ${formatTokenCount(total)}` + + return ( + + + + + ) } + +export function ComposerContextUsage({ tabId }: { tabId: string | null }) { + const usage = useComposerUsage(tabId) + return +} + +export function ComposerTokenSummary({ tabId }: { tabId: string | null }) { + const usage = useComposerUsage(tabId) + return +} + +/** + * Supplies both status-row indicators from one conversation snapshot. The + * render prop lets the caller keep the summary centered and the context ring + * trailing without installing duplicate store/connection subscriptions. + */ +export function ComposerUsageIndicators({ + tabId, + children, +}: { + tabId: string | null + children: (indicators: { + context: ReactNode + summary: ReactNode + }) => ReactNode +}) { + const usage = useComposerUsage(tabId) + return children({ + context: , + summary: , + }) +} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index e45a5591a6..3b295e7d16 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -91,14 +91,25 @@ vi.mock("@/components/chat/conversation-context-bar", () => ({ }) =>
{extraContent}
, // The composer imports these to render the below-input folder/branch row. // Hidden by default; the cold-start test resolves it after the editor mounts. - ConversationFolderBranchPicker: () => null, + ConversationFolderBranchPicker: () =>
, useConversationFolderBranchPickerVisible: folderPickerVisible, })) -vi.mock("./composer-context-usage", () => ({ - ComposerContextUsage: () => null, +vi.mock("@/components/chat/composer-context-usage", () => ({ + ComposerUsageIndicators: ({ + children, + }: { + children: (indicators: { + context: React.ReactNode + summary: React.ReactNode + }) => React.ReactNode + }) => + children({ + context: , + summary: , + }), })) -vi.mock("./composer-connection-status", () => ({ - ComposerConnectionStatus: () => null, +vi.mock("@/components/chat/composer-connection-status", () => ({ + ComposerConnectionStatus: () => , })) // The platform opener is the DESKTOP arm of the shared opener; this suite runs // in web mode, where a system-browser target lands on `window.open` instead. @@ -248,7 +259,10 @@ function renderInput( } describe("MessageInput (RichComposer integration)", () => { - afterEach(() => cleanup()) + afterEach(() => { + cleanup() + folderPickerVisible.mockReturnValue(false) + }) it("mounts and renders the rich-text composer surface", async () => { const { container } = renderInput({}) @@ -362,6 +376,23 @@ describe("MessageInput (RichComposer integration)", () => { firePointer(stop, "click", "touch") expect(document.activeElement).not.toBe(editor) }) + + it("gives the attached status row an ancestor size container", async () => { + folderPickerVisible.mockReturnValue(true) + const { container } = renderInput({ attachmentTabId: "tab-1" }) + const summary = await screen.findByTestId("token-summary") + + let queryContainer = summary.parentElement + while (queryContainer && !queryContainer.classList.contains("@container")) { + queryContainer = queryContainer.parentElement + } + + expect(queryContainer).not.toBeNull() + expect(queryContainer).toContainElement( + container.querySelector(".codeg-composer-chrome") + ) + expect(within(queryContainer!).getByTestId("folder-picker")).toBeVisible() + }) }) describe("MessageInput attach-to-chat insertion position", () => { diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 54c2534372..caf290167f 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -81,7 +81,7 @@ import { useConversationFolderBranchPickerVisible, type ConversationFolderPickerOverride, } from "@/components/chat/conversation-context-bar" -import { ComposerContextUsage } from "@/components/chat/composer-context-usage" +import { ComposerUsageIndicators } from "@/components/chat/composer-context-usage" import { ComposerConnectionStatus } from "@/components/chat/composer-connection-status" import { InlineModeSelector } from "@/components/chat/mode-selector" import { @@ -2140,7 +2140,7 @@ export function MessageInput({ className={cn( "block", folderBranchPickerAttached && - "overflow-hidden rounded-xl transition-colors", + "@container overflow-hidden rounded-xl transition-colors", folderBranchPickerAttached && showDragActive && "ring-1 ring-primary/40" @@ -2414,27 +2414,34 @@ export function MessageInput({ // above; the folder icon then aligns with the centered "+" icon (both // add the same 1px transparent border, paired with the picker buttons' // `px-1.5`). The row only renders while attached below the composer, so - // it always takes the rounded-bottom box treatment. Pickers sit at the - // left edge; the context-usage circle + agent connection status - // right-align at the trailing edge. -
-
- -
- {/* `pr-px` offsets the composer chrome's 1px border: the send button - sits INSIDE that border while this status row sits outside it, so - without the 1px nudge the trailing icon hangs 1px past the button. - With it, the connection icon's RIGHT edge is flush (0px) with the - send button's right edge in the action bar above — no centring - slot, which would inset the narrow icon and break the alignment. */} -
- - -
-
+ // it always takes the rounded-bottom box treatment. The side columns + // keep their intrinsic controls stable; only the centered token + // summary gives up space when a tiled panel becomes narrow. + + {({ context, summary }) => ( +
+
+ +
+
+ {summary} +
+ {/* `pr-px` offsets the composer chrome's 1px border: the send button + sits INSIDE that border while this status row sits outside it, so + without the 1px nudge the trailing icon hangs 1px past the button. + With it, the connection icon's RIGHT edge is flush (0px) with the + send button's right edge in the action bar above — no centring + slot, which would inset the narrow icon and break the alignment. */} +
+ {context} + +
+
+ )} +
)}
{!attach.showNativePaperclip && ( diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index 9a8a38139e..c2e0abfe6d 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -186,7 +186,7 @@ describe("ConversationDetailPanel new conversation layout", () => { const pickerWrapper = messageInputSource.slice(pickerStart, pickerEnd) expect(messageInputSource).toContain( - '"overflow-hidden rounded-xl transition-colors"' + '"@container overflow-hidden rounded-xl transition-colors"' ) expect(messageInputSource).not.toContain("bg-muted/60") // The rounded border lives in the always-on base (so the active-session flow @@ -217,11 +217,16 @@ describe("ConversationDetailPanel new conversation layout", () => { expect(pickerWrapper).not.toContain("pl-1.5") expect(pickerWrapper).not.toMatch(/\bborder-b\b/) expect(pickerWrapper).not.toMatch(/\bborder-x\b/) - // The context-usage circle + agent connection status moved here from the - // bottom status bar: they right-align at the trailing edge (justify-between) - // while the folder/branch pickers stay on the left. - expect(pickerWrapper).toContain("justify-between") - expect(pickerWrapper).toContain("