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("