FN-7141: add chat context token budget indicator

Add a desktop Direct-chat header indicator for estimated tokens used against the active model context window.

- Estimate chat token usage client-side, including streaming text, and format compact counts.
- Render the context-window badge only for non-mobile Direct-chat threads with known model context windows.
- Cover desktop, mobile, floating-narrow, rooms, unknown-context, and streaming surfaces with tests.
- Document the dashboard behavior and add a release changeset.

Files changed:
 .changeset/fn-7141-chat-context-window.md          |   7 +
 docs/dashboard-guide.md                            |   2 +
 packages/dashboard/app/components/ChatView.css     |  22 +++
 packages/dashboard/app/components/ChatView.tsx     |  35 +++-
 .../__tests__/ChatView.context-window.test.tsx     | 193 +++++++++++++++++++++
 .../app/utils/__tests__/estimateChatTokens.test.ts |  34 ++++
 packages/dashboard/app/utils/estimateChatTokens.ts |  29 ++++
 packages/i18n/locales/en/app.json                  |   1 +
 8 files changed, 322 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7141

Fusion-Task-Lineage: 3ede9af4-b8d6-4da6-996f-cd9277ef49ac

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 17:22:12 -07:00
parent 13d97b7c33
commit c468c161c2
8 changed files with 322 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Show an estimated token count against the model's context window in the chat thread header.
category: feature
dev: Client-side estimate via app/utils/estimateChatTokens.ts; context window from ModelInfo.contextWindow. Desktop Direct-chat header only; hidden on mobile, in rooms, and when the model context window is unknown.

View File

@@ -352,6 +352,8 @@ Chat view provides project-scoped conversations with agents.
- On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail.
- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
<!-- FNXC:ChatContextWindow 2026-06-27-00:00: Direct-chat docs must describe the desktop/tablet-only estimated token budget indicator and its intentional absence from mobile, narrow floating chat, and room headers. -->
- On desktop/tablet Direct chat, the thread header shows an estimated token count against the active model's known context window (for example `~12.3k / 200k`). It is hidden on mobile, narrow floating chat, rooms, and unknown-context-window models.
<!-- FNXC:ChatViewDocs 2026-06-23-11:48: Chat responsive docs need to distinguish the mobile all-bubble readability cap from the tablet main Chat assistant-only widening; Quick Chat remains governed by its narrow container so floating desktop hosts do not inherit the tablet measure. -->
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. On tablet-width main Chat containers, assistant/agent, streaming, and failure bubbles use a wider reading measure for long responses while user bubbles and narrow floating Quick Chat hosts stay bounded.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.

View File

@@ -487,6 +487,24 @@ The active-chat pane header must give the title the full available line to the L
flex-shrink: 0;
}
/*
FNXC:ChatContextWindow 2026-06-27-00:00:
The Direct-chat context budget indicator is a secondary desktop/tablet header affordance. Keep it badge-like and shrinkable so the thread title and render toggle retain priority, and hide it at mobile widths where the session switcher owns the header.
*/
.chat-thread-header-context {
flex: 0 1 auto;
min-width: 0;
max-width: calc(var(--space-xl) * 5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-xs);
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 2));
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-muted);
}
.chat-mobile-session-menu {
position: relative;
min-width: 0;
@@ -2146,6 +2164,10 @@ Queued-message banners stack above the composer input with a capped scroll area,
flex-shrink: 0;
}
.chat-thread-header-context {
display: none;
}
.chat-sidebar-scope-btn {
min-height: calc(var(--space-lg) * 2.25);
}

View File

@@ -55,6 +55,7 @@ import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
import { estimateChatTokens, formatTokenCount } from "../utils/estimateChatTokens";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ViewHeader } from "./ViewHeader";
@@ -1070,7 +1071,7 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId);
const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]);
const { defaultProvider, defaultModelId } = useModelsCache();
const { models, defaultProvider, defaultModelId } = useModelsCache();
const defaultModel = useMemo<DefaultModelSelection>(() => ({ provider: defaultProvider, modelId: defaultModelId }), [defaultModelId, defaultProvider]);
const { skills: discoveredSkills, loading: skillsLoading } = useDiscoveredSkillsCache(projectId);
const [showSkillMenu, setShowSkillMenu] = useState(false);
@@ -2679,6 +2680,19 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
activeSession?.agentId ? (agentsMap.get(activeSession.agentId) ?? null) : null,
defaultModel,
);
const activeContextWindow = useMemo(() => {
if (!activeResolvedModel?.provider || !activeResolvedModel.modelId) {
return null;
}
const matchedModel = models.find(
(model) => model.provider === activeResolvedModel.provider && model.id === activeResolvedModel.modelId,
);
return matchedModel?.contextWindow ? matchedModel.contextWindow : null;
}, [activeResolvedModel?.modelId, activeResolvedModel?.provider, models]);
const estimatedChatTokens = useMemo(
() => estimateChatTokens(messages, isStreaming ? streamingText : undefined),
[isStreaming, messages, streamingText],
);
const activeModelTag = formatModelTag(activeResolvedModel?.provider, activeResolvedModel?.modelId);
const activeModelProvider = activeResolvedModel?.provider ?? null;
const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0);
@@ -2710,6 +2724,15 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
: activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat";
const showThreadHeaderModelTag = Boolean(activeModelTag && activeModelTag !== threadHeaderTitle);
const showThreadHeaderContextWindow = !isChatMobile && hasThreadInView && activeContextWindow !== null;
const threadHeaderContextUsed = formatTokenCount(estimatedChatTokens);
const threadHeaderContextTotal = activeContextWindow !== null ? formatTokenCount(activeContextWindow) : null;
const threadHeaderContextLabel = threadHeaderContextTotal
? t("chat.contextWindowAria", "Estimated {{used}} of {{total}} context tokens", {
used: threadHeaderContextUsed,
total: threadHeaderContextTotal,
})
: null;
const showMobileSessionSwitcher = isChatMobile && chatScope === "direct" && !!activeSession;
const agentName =
@@ -4021,6 +4044,16 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? (
<span
className="chat-thread-header-context"
data-testid="chat-thread-context-window"
title={threadHeaderContextLabel}
aria-label={threadHeaderContextLabel}
>
{threadHeaderContextUsed} / {threadHeaderContextTotal}
</span>
) : null}
</>
)}
</div>

View File

@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { ChatView } from "../ChatView";
import {
activeSessionFixture,
defaultModelsResponse,
installChatViewEnv,
mockFetchModels,
mockViewportMode,
renderWithAct,
setupMockChat,
setupMockRooms,
createRoomFixture,
} from "./ChatView.test-harness";
import { estimateChatTokens, formatTokenCount } from "../../utils/estimateChatTokens";
/*
FNXC:DashboardTests 2026-06-27-00:00:
Chat context-window coverage is surface-enumeration driven: desktop Direct chat renders the estimate, while mobile, floating-narrow, rooms, and unknown-model states must omit the element entirely so no empty header shell survives in constrained layouts.
*/
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: [],
favoriteModels: [],
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
installChatViewEnv();
function expectNoContextWindowShell() {
expect(screen.queryByTestId("chat-thread-context-window")).not.toBeInTheDocument();
expect(document.querySelector(".chat-thread-header-context")).not.toBeInTheDocument();
}
function setupDirectChat(options: { content?: string; streamingText?: string } = {}) {
const content = options.content ?? "abcd";
setupMockChat({
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
activeSession: activeSessionFixture,
messages: [
{
id: "msg-001",
sessionId: activeSessionFixture.id,
role: "user",
content,
createdAt: "2026-04-08T00:00:00.000Z",
},
],
isStreaming: options.streamingText !== undefined,
streamingText: options.streamingText ?? "",
});
}
describe("ChatView context-window indicator", () => {
it("renders the estimated token budget in the desktop Direct-chat header", async () => {
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const indicator = await screen.findByTestId("chat-thread-context-window");
expect(indicator).toHaveTextContent("1 / 200k");
expect(indicator).toHaveAttribute("aria-label", "Estimated 1 of 200k context tokens");
});
it("does not render an indicator shell in mobile Direct chat", async () => {
const restoreViewport = mockViewportMode("mobile");
try {
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
expectNoContextWindowShell();
} finally {
restoreViewport.mockRestore();
}
});
it("does not render an indicator shell in the narrowed floating chat modal", async () => {
const restoreViewport = mockViewportMode("desktop");
const originalResizeObserver = globalThis.ResizeObserver;
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 560,
height: 640,
top: 0,
right: 560,
bottom: 640,
left: 0,
toJSON: () => ({}),
});
class MockResizeObserver implements ResizeObserver {
readonly observe = vi.fn();
readonly unobserve = vi.fn();
readonly disconnect = vi.fn();
constructor(_callback: ResizeObserverCallback) {}
}
globalThis.ResizeObserver = MockResizeObserver;
try {
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
expect(screen.getByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
expectNoContextWindowShell();
} finally {
globalThis.ResizeObserver = originalResizeObserver;
rectSpy.mockRestore();
restoreViewport.mockRestore();
}
});
it("does not render an indicator shell when the active model context window is unknown", async () => {
mockFetchModels.mockResolvedValue({
...defaultModelsResponse,
models: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 0 }],
});
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expectNoContextWindowShell();
});
});
it("includes in-flight streaming text in the displayed estimate", async () => {
const content = "a".repeat(3996);
const streamingText = "abcd";
setupDirectChat({ content, streamingText });
const expectedUsed = formatTokenCount(estimateChatTokens([{ content }], streamingText));
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const indicator = await screen.findByTestId("chat-thread-context-window");
expect(expectedUsed).toBe("~1k");
expect(indicator).toHaveTextContent(`${expectedUsed} / 200k`);
expect(indicator).not.toHaveTextContent("999 / 200k");
});
it("does not render an indicator shell in rooms scope", async () => {
const room = createRoomFixture("context-room");
localStorage.setItem("fusion:chat-scope", "rooms");
setupDirectChat({ content: "abcd" });
setupMockRooms({
rooms: [room],
activeRoom: room,
activeRoomMembers: [],
messages: [
{
id: "room-msg-001",
roomId: room.id,
role: "user",
content: "Room hello",
createdAt: "2026-04-08T00:00:00.000Z",
senderAgentId: null,
mentions: [],
},
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(document.querySelector(".chat-room-thread-header")).toBeInTheDocument();
expectNoContextWindowShell();
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { estimateChatTokens, formatTokenCount } from "../estimateChatTokens";
describe("estimateChatTokens", () => {
it("returns 0 for empty input", () => {
expect(estimateChatTokens([])).toBe(0);
});
it("sums multiple message contents with a four-character heuristic", () => {
expect(estimateChatTokens([{ content: "abcd" }, { content: "abcdefgh" }])).toBe(3);
});
it("includes streaming text in the estimate", () => {
expect(estimateChatTokens([{ content: "abcd" }], "abcdefgh")).toBe(3);
});
it("guards against null or undefined content", () => {
expect(estimateChatTokens([{ content: null }, {}, { content: "abcde" }])).toBe(2);
});
});
describe("formatTokenCount", () => {
it("renders sub-1k counts without a suffix", () => {
expect(formatTokenCount(980)).toBe("980");
});
it("renders one-decimal compact thousands below 10k", () => {
expect(formatTokenCount(1234)).toBe("~1.2k");
});
it("rounds larger thousands without a decimal", () => {
expect(formatTokenCount(200_000)).toBe("200k");
});
});

View File

@@ -0,0 +1,29 @@
export interface ChatTokenEstimateMessage {
content?: string | null;
}
/*
FNXC:ChatContextWindow 2026-06-27-00:00:
Direct chat does not persist provider token usage on ChatMessageInfo, so the header budget gauge must remain an explicit client-side estimate. Use the conservative four-characters-per-token heuristic and include live streaming text so long responses update without backend changes.
*/
export function estimateChatTokens(messages: ChatTokenEstimateMessage[], streamingText?: string | null): number {
const totalChars = messages.reduce((sum, message) => sum + (message.content?.length ?? 0), 0) + (streamingText?.length ?? 0);
return Math.ceil(totalChars / 4);
}
export function formatTokenCount(n: number): string {
if (!Number.isFinite(n) || n <= 0) {
return "0";
}
if (n < 1000) {
return String(Math.round(n));
}
const thousands = n / 1000;
if (thousands < 10) {
return `~${Number(thousands.toFixed(1))}k`;
}
return `${Math.round(thousands)}k`;
}

View File

@@ -1242,6 +1242,7 @@
"conversationDeleted": "Conversation deleted",
"conversationName": "Conversation name",
"conversationRenamed": "Conversation renamed",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"copyFailed": "Copy failed",
"copyResponse": "Copy response",
"create": "Create",