From c468c161c2da2aecdbab61d8d9a3dc43517c430c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 17:22:12 -0700 Subject: [PATCH] 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) --- .changeset/fn-7141-chat-context-window.md | 7 + docs/dashboard-guide.md | 2 + .../dashboard/app/components/ChatView.css | 22 ++ .../dashboard/app/components/ChatView.tsx | 35 +++- .../ChatView.context-window.test.tsx | 193 ++++++++++++++++++ .../__tests__/estimateChatTokens.test.ts | 34 +++ .../dashboard/app/utils/estimateChatTokens.ts | 29 +++ packages/i18n/locales/en/app.json | 1 + 8 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7141-chat-context-window.md create mode 100644 packages/dashboard/app/components/__tests__/ChatView.context-window.test.tsx create mode 100644 packages/dashboard/app/utils/__tests__/estimateChatTokens.test.ts create mode 100644 packages/dashboard/app/utils/estimateChatTokens.ts diff --git a/.changeset/fn-7141-chat-context-window.md b/.changeset/fn-7141-chat-context-window.md new file mode 100644 index 0000000000..7adeebed10 --- /dev/null +++ b/.changeset/fn-7141-chat-context-window.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index effafdff22..3ae866e1d7 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +- 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. - 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. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 44c9c761ee..d849a0f8f2 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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); } diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 35a861e028..438ace182f 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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(() => ({ 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 ? : } {threadHeaderTitle} {showThreadHeaderModelTag && {activeModelTag}} + {showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? ( + + {threadHeaderContextUsed} / {threadHeaderContextTotal} + + ) : null} )} diff --git a/packages/dashboard/app/components/__tests__/ChatView.context-window.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.context-window.test.tsx new file mode 100644 index 0000000000..2c8798567e --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.context-window.test.tsx @@ -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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(document.querySelector(".chat-room-thread-header")).toBeInTheDocument(); + expectNoContextWindowShell(); + }); +}); diff --git a/packages/dashboard/app/utils/__tests__/estimateChatTokens.test.ts b/packages/dashboard/app/utils/__tests__/estimateChatTokens.test.ts new file mode 100644 index 0000000000..a8143e4346 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/estimateChatTokens.test.ts @@ -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"); + }); +}); diff --git a/packages/dashboard/app/utils/estimateChatTokens.ts b/packages/dashboard/app/utils/estimateChatTokens.ts new file mode 100644 index 0000000000..0f3bc51555 --- /dev/null +++ b/packages/dashboard/app/utils/estimateChatTokens.ts @@ -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`; +} diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index dcc51d76a8..c1eb6456e5 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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",