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:
7
.changeset/fn-7141-chat-context-window.md
Normal file
7
.changeset/fn-7141-chat-context-window.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
29
packages/dashboard/app/utils/estimateChatTokens.ts
Normal file
29
packages/dashboard/app/utils/estimateChatTokens.ts
Normal 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`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user