+ setLocalSettings("tokenUsage", preferences)
+ }
+ />
+ ) : (
+
+ )}
{browserEnabled && }
diff --git a/frontend/src/components/workspace/context-usage-badge.tsx b/frontend/src/components/workspace/context-usage-badge.tsx
new file mode 100644
index 000000000..99082f784
--- /dev/null
+++ b/frontend/src/components/workspace/context-usage-badge.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { GaugeIcon } from "lucide-react";
+
+import { useI18n } from "@/core/i18n/hooks";
+import type { ContextUsage } from "@/core/threads/token-usage";
+import { cn } from "@/lib/utils";
+
+import { formatContextUsagePercentage } from "./context-usage-format";
+
+interface ContextUsageBadgeProps {
+ contextUsage: ContextUsage | null;
+ className?: string;
+}
+
+export function ContextUsageBadge({
+ contextUsage,
+ className,
+}: ContextUsageBadgeProps) {
+ const { t } = useI18n();
+
+ const formatted = formatContextUsagePercentage(contextUsage?.percentage);
+ if (formatted == null) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {t.contextUsage.label}
+ {formatted}%
+
+ );
+}
diff --git a/frontend/src/components/workspace/context-usage-format.ts b/frontend/src/components/workspace/context-usage-format.ts
new file mode 100644
index 000000000..6af5753b1
--- /dev/null
+++ b/frontend/src/components/workspace/context-usage-format.ts
@@ -0,0 +1,19 @@
+/**
+ * Format a context-usage percentage for display.
+ *
+ * Returns `null` when the percentage is unknown so callers can choose to
+ * hide the indicator entirely rather than render a placeholder.
+ *
+ * Whole-number percentages are rendered without a decimal point; otherwise
+ * a single decimal place is shown to stay readable at high resolution
+ * (e.g. ``35.4%``).
+ */
+export function formatContextUsagePercentage(
+ percentage: number | null | undefined,
+): string | null {
+ if (typeof percentage !== "number" || !Number.isFinite(percentage)) {
+ return null;
+ }
+ const clamped = Math.max(0, percentage);
+ return Number.isInteger(clamped) ? `${clamped}` : clamped.toFixed(1);
+}
diff --git a/frontend/src/components/workspace/token-usage-indicator.tsx b/frontend/src/components/workspace/token-usage-indicator.tsx
index 0fef98ab0..61b75cdb4 100644
--- a/frontend/src/components/workspace/token-usage-indicator.tsx
+++ b/frontend/src/components/workspace/token-usage-indicator.tsx
@@ -26,13 +26,17 @@ import {
type TokenUsagePreferences,
type TokenUsageViewPreset,
} from "@/core/messages/usage-model";
+import type { ContextUsage } from "@/core/threads/token-usage";
import { cn } from "@/lib/utils";
+import { formatContextUsagePercentage } from "./context-usage-format";
+
interface TokenUsageIndicatorProps {
threadId?: string;
messages: Message[];
pendingMessages?: Message[];
backendUsage?: TokenUsage | null;
+ contextUsage?: ContextUsage | null;
enabled?: boolean;
preferences: TokenUsagePreferences;
onPreferencesChange: (preferences: TokenUsagePreferences) => void;
@@ -44,6 +48,7 @@ export function TokenUsageIndicator({
messages,
pendingMessages,
backendUsage,
+ contextUsage,
enabled = false,
preferences,
onPreferencesChange,
@@ -61,6 +66,9 @@ export function TokenUsageIndicator({
[backendUsage, messages, pendingMessages, threadId],
);
const preset = getTokenUsageViewPreset(preferences);
+ const contextPercentage = formatContextUsagePercentage(
+ contextUsage?.percentage,
+ );
if (!enabled) {
return null;
@@ -86,6 +94,14 @@ export function TokenUsageIndicator({
: "-"
: t.tokenUsage.presets[presetKeyToTranslationKey(preset)]}
+ {contextPercentage != null && (
+
+ {contextPercentage}%
+
+ )}
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index ed1572af1..499e1e12b 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -699,6 +699,13 @@ export const enUS: Translations = {
removeTodo: (content: string) => `Remove To-do: ${content}`,
},
+ contextUsage: {
+ label: "Context",
+ title: "Context window",
+ badgeAriaLabel: (percentage: string) =>
+ `Context window ${percentage}% full`,
+ },
+
// Shortcuts
shortcuts: {
searchActions: "Search actions...",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index e2f035b23..5a7db16d8 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -582,6 +582,12 @@ export interface Translations {
removeTodo: (content: string) => string;
};
+ contextUsage: {
+ label: string;
+ title: string;
+ badgeAriaLabel: (percentage: string) => string;
+ };
+
// Shortcuts
shortcuts: {
searchActions: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 47fb91b1a..780665817 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -672,6 +672,12 @@ export const zhCN: Translations = {
removeTodo: (content: string) => `移除 To-do:${content}`,
},
+ contextUsage: {
+ label: "上下文",
+ title: "上下文窗口",
+ badgeAriaLabel: (percentage: string) => `上下文窗口已使用 ${percentage}%`,
+ },
+
// Shortcuts
shortcuts: {
searchActions: "搜索操作...",
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index 888a4a6c1..fdfbb2539 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -41,7 +41,10 @@ import {
filterThreadSearchResults,
type ThreadSearchParams,
} from "./thread-search-query";
-import { threadTokenUsageQueryKey } from "./token-usage";
+import {
+ retainThreadTokenUsagePlaceholder,
+ threadTokenUsageQueryKey,
+} from "./token-usage";
import type {
AgentThread,
AgentThreadState,
@@ -2771,6 +2774,10 @@ export function useThreadTokenUsage(
enabled: enabled && Boolean(threadId),
retry: false,
refetchOnWindowFocus: false,
+ // Keep same-thread data visible during refetches without carrying usage
+ // from the previous route into a newly selected thread.
+ placeholderData: (previous) =>
+ retainThreadTokenUsagePlaceholder(previous, threadId),
});
}
diff --git a/frontend/src/core/threads/token-usage.ts b/frontend/src/core/threads/token-usage.ts
index 89455eef9..e6354531d 100644
--- a/frontend/src/core/threads/token-usage.ts
+++ b/frontend/src/core/threads/token-usage.ts
@@ -6,6 +6,13 @@ export function threadTokenUsageQueryKey(threadId?: string | null) {
return ["thread-token-usage", threadId] as const;
}
+export function retainThreadTokenUsagePlaceholder(
+ previous: ThreadTokenUsageResponse | null | undefined,
+ threadId?: string | null,
+): ThreadTokenUsageResponse | undefined {
+ return previous && previous.thread_id === threadId ? previous : undefined;
+}
+
export function threadTokenUsageToTokenUsage(
usage: ThreadTokenUsageResponse | null | undefined,
): TokenUsage | null {
@@ -18,3 +25,23 @@ export function threadTokenUsageToTokenUsage(
totalTokens: usage.total_tokens ?? 0,
};
}
+
+export interface ContextUsage {
+ tokenCount: number;
+ maxContextTokens: number | null;
+ percentage: number | null;
+}
+
+export function selectContextUsage(
+ usage: ThreadTokenUsageResponse | null | undefined,
+): ContextUsage | null {
+ if (!usage?.context_usage) {
+ return null;
+ }
+ const { token_count, max_context_tokens, percentage } = usage.context_usage;
+ return {
+ tokenCount: token_count ?? 0,
+ maxContextTokens: max_context_tokens ?? null,
+ percentage: percentage ?? null,
+ };
+}
diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts
index e4578e147..d7751955f 100644
--- a/frontend/src/core/threads/types.ts
+++ b/frontend/src/core/threads/types.ts
@@ -62,6 +62,12 @@ export interface RunMessage {
created_at: string;
}
+export interface ThreadContextUsage {
+ token_count: number;
+ max_context_tokens: number | null;
+ percentage: number | null;
+}
+
export interface ThreadTokenUsageResponse {
thread_id: string;
total_tokens: number;
@@ -74,4 +80,5 @@ export interface ThreadTokenUsageResponse {
subagent: number;
middleware: number;
};
+ context_usage?: ThreadContextUsage | null;
}
diff --git a/frontend/tests/unit/components/workspace/context-usage-badge.test.ts b/frontend/tests/unit/components/workspace/context-usage-badge.test.ts
new file mode 100644
index 000000000..4cd5fe5a7
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/context-usage-badge.test.ts
@@ -0,0 +1,42 @@
+import { expect, rs, test } from "@rstest/core";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { ContextUsageBadge } from "@/components/workspace/context-usage-badge";
+
+rs.mock("@/core/i18n/hooks", () => ({
+ useI18n: () => ({
+ t: {
+ contextUsage: {
+ title: "Context window",
+ label: "Context",
+ badgeAriaLabel: (percentage: string) =>
+ `Context window ${percentage}% full`,
+ },
+ },
+ }),
+}));
+
+test("keeps a gauge placeholder visible while context usage is unavailable", () => {
+ const html = renderToStaticMarkup(
+ createElement(ContextUsageBadge, { contextUsage: null }),
+ );
+
+ expect(html).toContain('data-context-usage-placeholder="true"');
+ expect(html).toContain('aria-label="Context window"');
+});
+
+test("renders the current percentage when context usage is available", () => {
+ const html = renderToStaticMarkup(
+ createElement(ContextUsageBadge, {
+ contextUsage: {
+ tokenCount: 35_000,
+ maxContextTokens: 100_000,
+ percentage: 35,
+ },
+ }),
+ );
+
+ expect(html).toContain("35%");
+ expect(html).toContain('aria-label="Context window 35% full"');
+});
diff --git a/frontend/tests/unit/components/workspace/context-usage-format.test.ts b/frontend/tests/unit/components/workspace/context-usage-format.test.ts
new file mode 100644
index 000000000..34654258a
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/context-usage-format.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "@rstest/core";
+
+import { formatContextUsagePercentage } from "@/components/workspace/context-usage-format";
+
+test("returns null for unknown percentages", () => {
+ expect(formatContextUsagePercentage(null)).toBeNull();
+ expect(formatContextUsagePercentage(undefined)).toBeNull();
+ expect(formatContextUsagePercentage(Number.NaN)).toBeNull();
+});
+
+test("renders whole numbers without a decimal point", () => {
+ expect(formatContextUsagePercentage(0)).toBe("0");
+ expect(formatContextUsagePercentage(35)).toBe("35");
+ expect(formatContextUsagePercentage(100)).toBe("100");
+});
+
+test("renders fractional percentages with one decimal place", () => {
+ expect(formatContextUsagePercentage(35.4)).toBe("35.4");
+ expect(formatContextUsagePercentage(35.46)).toBe("35.5");
+});
+
+test("clamps negative percentages to zero", () => {
+ expect(formatContextUsagePercentage(-1)).toBe("0");
+});
diff --git a/frontend/tests/unit/core/threads/token-usage.test.ts b/frontend/tests/unit/core/threads/token-usage.test.ts
index 6e73cca1b..9b69621c5 100644
--- a/frontend/tests/unit/core/threads/token-usage.test.ts
+++ b/frontend/tests/unit/core/threads/token-usage.test.ts
@@ -1,6 +1,12 @@
import { expect, test } from "@rstest/core";
+import { QueryClient, QueryObserver } from "@tanstack/react-query";
-import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
+import {
+ retainThreadTokenUsagePlaceholder,
+ selectContextUsage,
+ threadTokenUsageQueryKey,
+ threadTokenUsageToTokenUsage,
+} from "@/core/threads/token-usage";
import type { ThreadTokenUsageResponse } from "@/core/threads/types";
test("maps backend thread token usage to UI token usage", () => {
@@ -29,3 +35,150 @@ test("returns null when backend thread token usage is unavailable", () => {
expect(threadTokenUsageToTokenUsage(null)).toBeNull();
expect(threadTokenUsageToTokenUsage(undefined)).toBeNull();
});
+
+test("retains placeholder usage only for the current thread", () => {
+ const response: ThreadTokenUsageResponse = {
+ thread_id: "thread-1",
+ total_input_tokens: 90,
+ total_output_tokens: 60,
+ total_tokens: 150,
+ total_runs: 2,
+ by_model: { unknown: { tokens: 150, runs: 2 } },
+ by_caller: {
+ lead_agent: 120,
+ subagent: 25,
+ middleware: 5,
+ },
+ };
+
+ expect(retainThreadTokenUsagePlaceholder(response, "thread-1")).toBe(
+ response,
+ );
+ expect(
+ retainThreadTokenUsagePlaceholder(response, "thread-2"),
+ ).toBeUndefined();
+ expect(retainThreadTokenUsagePlaceholder(null, undefined)).toBeUndefined();
+});
+
+test("query observer keeps same-thread data but drops it while a new thread is pending", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const threadA: ThreadTokenUsageResponse = {
+ thread_id: "thread-a",
+ total_input_tokens: 90,
+ total_output_tokens: 60,
+ total_tokens: 150,
+ total_runs: 2,
+ by_model: { unknown: { tokens: 150, runs: 2 } },
+ by_caller: { lead_agent: 120, subagent: 25, middleware: 5 },
+ };
+ const threadB: ThreadTokenUsageResponse = {
+ ...threadA,
+ thread_id: "thread-b",
+ total_tokens: 200,
+ };
+ let queryResult = Promise.resolve(threadA);
+ let resolveRefresh: (value: ThreadTokenUsageResponse) => void = () =>
+ undefined;
+ let resolveThreadB: (value: ThreadTokenUsageResponse) => void = () =>
+ undefined;
+ const observer = new QueryObserver(
+ queryClient,
+ {
+ queryKey: threadTokenUsageQueryKey("thread-a"),
+ queryFn: () => queryResult,
+ placeholderData: (previous) =>
+ retainThreadTokenUsagePlaceholder(previous, "thread-a"),
+ },
+ );
+ const unsubscribe = observer.subscribe(() => undefined);
+
+ try {
+ await observer.refetch();
+ expect(observer.getCurrentResult().data).toBe(threadA);
+
+ queryResult = new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+ const sameThreadRefetch = observer.refetch();
+ expect(observer.getCurrentResult().data).toBe(threadA);
+ resolveRefresh(threadA);
+ await sameThreadRefetch;
+
+ const pendingThreadB = new Promise((resolve) => {
+ resolveThreadB = resolve;
+ });
+ observer.setOptions({
+ queryKey: threadTokenUsageQueryKey("thread-b"),
+ queryFn: () => pendingThreadB,
+ retry: false,
+ placeholderData: (previous) =>
+ retainThreadTokenUsagePlaceholder(previous, "thread-b"),
+ });
+ expect(observer.getCurrentResult().data).toBeUndefined();
+ expect(observer.getCurrentResult().isPlaceholderData).toBe(false);
+
+ resolveThreadB(threadB);
+ await observer.refetch();
+ expect(observer.getCurrentResult().data).toBe(threadB);
+ } finally {
+ resolveRefresh(threadA);
+ resolveThreadB(threadB);
+ unsubscribe();
+ queryClient.clear();
+ }
+});
+
+const _baseResponse = {
+ thread_id: "thread-1",
+ total_input_tokens: 0,
+ total_output_tokens: 0,
+ total_tokens: 0,
+ total_runs: 0,
+ by_model: {},
+ by_caller: { lead_agent: 0, subagent: 0, middleware: 0 },
+} satisfies ThreadTokenUsageResponse;
+
+test("selectContextUsage projects the backend block to UI shape", () => {
+ const response: ThreadTokenUsageResponse = {
+ ..._baseResponse,
+ context_usage: {
+ token_count: 350,
+ max_context_tokens: 1000,
+ percentage: 35,
+ },
+ };
+
+ expect(selectContextUsage(response)).toEqual({
+ tokenCount: 350,
+ maxContextTokens: 1000,
+ percentage: 35,
+ });
+});
+
+test("selectContextUsage preserves nullable capacity and percentage", () => {
+ const response: ThreadTokenUsageResponse = {
+ ..._baseResponse,
+ context_usage: {
+ token_count: 200,
+ max_context_tokens: null,
+ percentage: null,
+ },
+ };
+
+ expect(selectContextUsage(response)).toEqual({
+ tokenCount: 200,
+ maxContextTokens: null,
+ percentage: null,
+ });
+});
+
+test("selectContextUsage returns null when context_usage is missing", () => {
+ expect(selectContextUsage(_baseResponse)).toBeNull();
+ expect(
+ selectContextUsage({ ..._baseResponse, context_usage: null }),
+ ).toBeNull();
+ expect(selectContextUsage(null)).toBeNull();
+ expect(selectContextUsage(undefined)).toBeNull();
+});