From bc64e710083d30385529810b491d02db7fca0ada Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:06:12 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20Add=20always-visible=20vertical?= =?UTF-8?q?=20token=20meter=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When MetaSidebar space is limited, collapse to a 20px vertical bar showing the last request's token composition. Bar is proportional to context window usage and stays visible via sticky positioning on small screens. Key changes: - Responsive sidebar: 300px full view ↔ 20px collapsed with hysteresis - Vertical token meter: color-coded segments, hover for details - Sticky positioning: bar remains visible when window < 770px - Performance: throttled ResizeObserver (max 60fps), memoized calculations - DRY: unified calculateTokenMeterData() for horizontal/vertical views Technical details: - Observe ChatArea width directly (avoids circular dependency) - Hysteresis thresholds (800px/1100px) prevent oscillation - Segments sized proportionally to request composition - Context % label shows window usage (e.g., "18" for 18%) _Generated with `cmux`_ --- src/components/AIView.tsx | 11 +- src/components/ChatMetaSidebar.tsx | 159 +++++++++++----- src/components/ChatMetaSidebar/CostsTab.tsx | 27 +-- src/components/ChatMetaSidebar/TokenMeter.tsx | 67 +++++++ .../ChatMetaSidebar/VerticalTokenMeter.tsx | 173 ++++++++++++++++++ src/hooks/useResizeObserver.ts | 61 ++++++ src/utils/tokens/tokenMeterUtils.ts | 107 +++++++++++ 7 files changed, 541 insertions(+), 64 deletions(-) create mode 100644 src/components/ChatMetaSidebar/TokenMeter.tsx create mode 100644 src/components/ChatMetaSidebar/VerticalTokenMeter.tsx create mode 100644 src/hooks/useResizeObserver.ts create mode 100644 src/utils/tokens/tokenMeterUtils.ts diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 31faf6236..01b116ce7 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -38,7 +38,8 @@ const ViewContainer = styled.div` color: #d4d4d4; font-family: var(--font-monospace); font-size: 12px; - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; container-type: inline-size; `; @@ -203,6 +204,8 @@ const AIViewInner: React.FC = ({ workspacePath, className, }) => { + const chatAreaRef = useRef(null); + // NEW: Get workspace state from store (only re-renders when THIS workspace changes) const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); @@ -346,7 +349,7 @@ const AIViewInner: React.FC = ({ if (!workspaceState) { return ( - + Loading workspace... @@ -405,7 +408,7 @@ const AIViewInner: React.FC = ({ return ( - + = ({ /> - + ); diff --git a/src/components/ChatMetaSidebar.tsx b/src/components/ChatMetaSidebar.tsx index d6441cd08..149df7448 100644 --- a/src/components/ChatMetaSidebar.tsx +++ b/src/components/ChatMetaSidebar.tsx @@ -1,20 +1,48 @@ import React from "react"; import styled from "@emotion/styled"; import { usePersistedState } from "@/hooks/usePersistedState"; +import { useChatContext } from "@/contexts/ChatContext"; +import { use1MContext } from "@/hooks/use1MContext"; +import { useResizeObserver } from "@/hooks/useResizeObserver"; import { CostsTab } from "./ChatMetaSidebar/CostsTab"; import { ToolsTab } from "./ChatMetaSidebar/ToolsTab"; +import { VerticalTokenMeter } from "./ChatMetaSidebar/VerticalTokenMeter"; +import { calculateTokenMeterData } from "@/utils/tokens/tokenMeterUtils"; -const SidebarContainer = styled.div` - width: 300px; +interface SidebarContainerProps { + collapsed: boolean; +} + +const SidebarContainer = styled.div` + width: ${(props) => (props.collapsed ? "20px" : "300px")}; background: #252526; border-left: 1px solid #3e3e42; display: flex; flex-direction: column; overflow: hidden; + transition: width 0.2s ease; + flex-shrink: 0; - @container (max-width: 949px) { - display: none; - } + /* Keep vertical bar always visible when collapsed */ + ${(props) => + props.collapsed && + ` + position: sticky; + right: 0; + z-index: 10; + box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2); + `} +`; + +const FullView = styled.div<{ visible: boolean }>` + display: ${(props) => (props.visible ? "flex" : "none")}; + flex-direction: column; + height: 100%; +`; + +const CollapsedView = styled.div<{ visible: boolean }>` + display: ${(props) => (props.visible ? "flex" : "none")}; + height: 100%; `; const TabBar = styled.div` @@ -56,58 +84,103 @@ type TabType = "costs" | "tools"; interface ChatMetaSidebarProps { workspaceId: string; + chatAreaRef: React.RefObject; } -export const ChatMetaSidebar: React.FC = ({ workspaceId }) => { +export const ChatMetaSidebar: React.FC = ({ workspaceId, chatAreaRef }) => { const [selectedTab, setSelectedTab] = usePersistedState( `chat-meta-sidebar-tab:${workspaceId}`, "costs" ); + const { stats } = useChatContext(); + const [use1M] = use1MContext(); + const chatAreaSize = useResizeObserver(chatAreaRef); + const baseId = `chat-meta-${workspaceId}`; const costsTabId = `${baseId}-tab-costs`; const toolsTabId = `${baseId}-tab-tools`; const costsPanelId = `${baseId}-panel-costs`; const toolsPanelId = `${baseId}-panel-tools`; + const lastUsage = stats?.usageHistory[stats.usageHistory.length - 1]; + + // Memoize vertical meter data calculation to prevent unnecessary re-renders + const verticalMeterData = React.useMemo(() => { + return lastUsage && stats + ? calculateTokenMeterData(lastUsage, stats.model, use1M, true) + : { segments: [], totalTokens: 0, totalPercentage: 0 }; + }, [lastUsage, stats, use1M]); + + // Calculate if we should show collapsed view with hysteresis + // Strategy: Observe ChatArea width directly (independent of sidebar width) + // - ChatArea has min-width: 750px and flex: 1 + // - Use hysteresis to prevent oscillation: + // * Collapse when chatAreaWidth <= 800px (tight space) + // * Expand when chatAreaWidth >= 1100px (lots of space) + // * Between 800-1100: maintain current state (dead zone) + const COLLAPSE_THRESHOLD = 800; // Collapse below this + const EXPAND_THRESHOLD = 1100; // Expand above this + const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash + + const [showCollapsed, setShowCollapsed] = React.useState(false); + + React.useEffect(() => { + if (chatAreaWidth <= COLLAPSE_THRESHOLD) { + setShowCollapsed(true); + } else if (chatAreaWidth >= EXPAND_THRESHOLD) { + setShowCollapsed(false); + } + // Between thresholds: maintain current state (no change) + }, [chatAreaWidth]); + return ( - - - setSelectedTab("costs")} - id={costsTabId} - role="tab" - type="button" - aria-selected={selectedTab === "costs"} - aria-controls={costsPanelId} - > - Costs - - setSelectedTab("tools")} - id={toolsTabId} - role="tab" - type="button" - aria-selected={selectedTab === "tools"} - aria-controls={toolsPanelId} - > - Tools - - - - {selectedTab === "costs" && ( -
- -
- )} - {selectedTab === "tools" && ( -
- -
- )} -
+ + + + setSelectedTab("costs")} + id={costsTabId} + role="tab" + type="button" + aria-selected={selectedTab === "costs"} + aria-controls={costsPanelId} + > + Costs + + setSelectedTab("tools")} + id={toolsTabId} + role="tab" + type="button" + aria-selected={selectedTab === "tools"} + aria-controls={toolsPanelId} + > + Tools + + + + {selectedTab === "costs" && ( +
+ +
+ )} + {selectedTab === "tools" && ( +
+ +
+ )} +
+
+ + +
); }; diff --git a/src/components/ChatMetaSidebar/CostsTab.tsx b/src/components/ChatMetaSidebar/CostsTab.tsx index 98eef8699..8de087c79 100644 --- a/src/components/ChatMetaSidebar/CostsTab.tsx +++ b/src/components/ChatMetaSidebar/CostsTab.tsx @@ -8,6 +8,7 @@ import { usePersistedState } from "@/hooks/usePersistedState"; import { ToggleGroup, type ToggleOption } from "../ToggleGroup"; import { use1MContext } from "@/hooks/use1MContext"; import { supports1MContext } from "@/utils/ai/models"; +import { TOKEN_COMPONENT_COLORS } from "@/utils/tokens/tokenMeterUtils"; const Container = styled.div` color: #d4d4d4; @@ -86,14 +87,6 @@ interface SegmentProps { percentage: number; } -// Component color mapping - single source of truth for all cost component colors -const COMPONENT_COLORS = { - cached: "var(--color-token-cached)", - input: "var(--color-token-input)", - output: "var(--color-token-output)", - thinking: "var(--color-thinking-mode)", -} as const; - const FixedSegment = styled.div` height: 100%; width: ${(props) => props.percentage}%; @@ -111,28 +104,28 @@ const VariableSegment = styled.div` const InputSegment = styled.div` height: 100%; width: ${(props) => props.percentage}%; - background: ${COMPONENT_COLORS.input}; + background: ${TOKEN_COMPONENT_COLORS.input}; transition: width 0.3s ease; `; const OutputSegment = styled.div` height: 100%; width: ${(props) => props.percentage}%; - background: ${COMPONENT_COLORS.output}; + background: ${TOKEN_COMPONENT_COLORS.output}; transition: width 0.3s ease; `; const ThinkingSegment = styled.div` height: 100%; width: ${(props) => props.percentage}%; - background: ${COMPONENT_COLORS.thinking}; + background: ${TOKEN_COMPONENT_COLORS.thinking}; transition: width 0.3s ease; `; const CachedSegment = styled.div` height: 100%; width: ${(props) => props.percentage}%; - background: ${COMPONENT_COLORS.cached}; + background: ${TOKEN_COMPONENT_COLORS.cached}; transition: width 0.3s ease; `; @@ -452,35 +445,35 @@ export const CostsTab: React.FC = () => { name: "Cache Read", tokens: displayUsage.cached.tokens, cost: displayUsage.cached.cost_usd, - color: COMPONENT_COLORS.cached, + color: TOKEN_COMPONENT_COLORS.cached, show: displayUsage.cached.tokens > 0, }, { name: "Cache Create", tokens: displayUsage.cacheCreate.tokens, cost: displayUsage.cacheCreate.cost_usd, - color: COMPONENT_COLORS.cached, + color: TOKEN_COMPONENT_COLORS.cached, show: displayUsage.cacheCreate.tokens > 0, }, { name: "Input", tokens: displayUsage.input.tokens, cost: adjustedInputCost, - color: COMPONENT_COLORS.input, + color: TOKEN_COMPONENT_COLORS.input, show: true, }, { name: "Output", tokens: displayUsage.output.tokens, cost: adjustedOutputCost, - color: COMPONENT_COLORS.output, + color: TOKEN_COMPONENT_COLORS.output, show: true, }, { name: "Thinking", tokens: displayUsage.reasoning.tokens, cost: adjustedReasoningCost, - color: COMPONENT_COLORS.thinking, + color: TOKEN_COMPONENT_COLORS.thinking, show: displayUsage.reasoning.tokens > 0, }, ].filter((c) => c.show) diff --git a/src/components/ChatMetaSidebar/TokenMeter.tsx b/src/components/ChatMetaSidebar/TokenMeter.tsx new file mode 100644 index 000000000..b45eb54fb --- /dev/null +++ b/src/components/ChatMetaSidebar/TokenMeter.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { TokenSegment } from "@/utils/tokens/tokenMeterUtils"; + +interface TokenMeterProps { + segments: TokenSegment[]; + orientation: "horizontal" | "vertical"; + className?: string; + style?: React.CSSProperties; +} + +const Bar = styled.div<{ orientation: "horizontal" | "vertical" }>` + background: #3e3e42; + border-radius: ${(props) => (props.orientation === "horizontal" ? "3px" : "4px")}; + overflow: hidden; + display: flex; + flex-direction: ${(props) => (props.orientation === "horizontal" ? "row" : "column")}; + ${(props) => + props.orientation === "horizontal" ? "width: 100%; height: 6px;" : "width: 8px; height: 100%;"} +`; + +const Segment = styled.div<{ + percentage: number; + color: string; + orientation: "horizontal" | "vertical"; +}>` + background: ${(props) => props.color}; + transition: ${(props) => (props.orientation === "horizontal" ? "width" : "flex-grow")} 0.3s ease; + ${(props) => + props.orientation === "horizontal" + ? `width: ${props.percentage}%; height: 100%;` + : `flex: ${props.percentage}; width: 100%;`} +`; + +const TokenMeterComponent: React.FC = ({ + segments, + orientation, + className, + style, + ...rest +}) => { + return ( + + {segments.map((seg, i) => ( + + ))} + + ); +}; + +// Memoize to prevent re-renders when props haven't changed +export const TokenMeter = React.memo(TokenMeterComponent); diff --git a/src/components/ChatMetaSidebar/VerticalTokenMeter.tsx b/src/components/ChatMetaSidebar/VerticalTokenMeter.tsx new file mode 100644 index 000000000..944dfff1b --- /dev/null +++ b/src/components/ChatMetaSidebar/VerticalTokenMeter.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { TokenMeter } from "./TokenMeter"; +import { type TokenMeterData, formatTokens, getSegmentLabel } from "@/utils/tokens/tokenMeterUtils"; + +const Container = styled.div` + width: 20px; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + background: #252526; + border-left: 1px solid #3e3e42; +`; + +const PercentageLabel = styled.div` + font-family: var(--font-primary); + font-size: 8px; + font-weight: 600; + color: #cccccc; + margin-bottom: 4px; + text-align: center; + flex-shrink: 0; +`; + +const MeterWrapper = styled.div` + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + min-height: 0; +`; + +const EmptySpace = styled.div<{ percentage: number }>` + flex: ${(props) => Math.max(0, 100 - props.percentage)}; + width: 100%; +`; + +const MeterContainer = styled.div<{ percentage: number }>` + flex: ${(props) => props.percentage}; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + min-height: 20px; +`; + +const BarWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + /* Force TooltipWrapper to expand to fill height */ + > * { + flex: 1; + display: flex; + flex-direction: column; + } +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + font-family: var(--font-primary); + font-size: 12px; +`; + +const Row = styled.div` + display: flex; + justify-content: space-between; + gap: 16px; +`; + +const Dot = styled.div<{ color: string }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(props) => props.color}; + flex-shrink: 0; +`; + +const Divider = styled.div` + border-top: 1px solid #3e3e42; + margin: 4px 0; +`; + +const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data }) => { + if (data.segments.length === 0) return null; + + // Scale the bar based on context window usage (0-100%) + const usagePercentage = data.maxTokens ? data.totalPercentage : 100; + + return ( + + {data.maxTokens && ( + + {Math.round(data.totalPercentage)} + + )} + + + + + + + +
+ Last Request +
+ + {data.segments.map((seg, i) => ( + +
+ + {getSegmentLabel(seg.type)} +
+ + {formatTokens(seg.tokens)} + +
+ ))} + +
+ Total: {formatTokens(data.totalTokens)} + {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} + {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} +
+
+
+
+
+
+ +
+
+ ); +}; + +// Memoize to prevent re-renders when data hasn't changed +export const VerticalTokenMeter = React.memo(VerticalTokenMeterComponent); diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts new file mode 100644 index 000000000..6cd371ce8 --- /dev/null +++ b/src/hooks/useResizeObserver.ts @@ -0,0 +1,61 @@ +import { useEffect, useState, useRef, type RefObject } from "react"; + +interface Size { + width: number; + height: number; +} + +/** + * Observes an element's size changes using ResizeObserver with throttling + * to prevent excessive re-renders during continuous resize operations. + */ +export function useResizeObserver(ref: RefObject): Size | null { + const [size, setSize] = useState(null); + const frameRef = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new ResizeObserver((entries) => { + // Throttle updates using requestAnimationFrame + // Only one update per frame, preventing excessive re-renders + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + } + + frameRef.current = requestAnimationFrame(() => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + // Round to nearest pixel to prevent sub-pixel re-renders + const roundedWidth = Math.round(width); + const roundedHeight = Math.round(height); + + setSize((prev) => { + // Only update if size actually changed + if (prev?.width === roundedWidth && prev?.height === roundedHeight) { + return prev; + } + return { width: roundedWidth, height: roundedHeight }; + }); + } + frameRef.current = null; + }); + }); + + observer.observe(element); + + // Set initial size + const { width, height } = element.getBoundingClientRect(); + setSize({ width: Math.round(width), height: Math.round(height) }); + + return () => { + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + } + observer.disconnect(); + }; + }, [ref]); + + return size; +} diff --git a/src/utils/tokens/tokenMeterUtils.ts b/src/utils/tokens/tokenMeterUtils.ts new file mode 100644 index 000000000..fae341ea1 --- /dev/null +++ b/src/utils/tokens/tokenMeterUtils.ts @@ -0,0 +1,107 @@ +import type { ChatUsageDisplay } from "./usageAggregator"; +import { getModelStats } from "./modelStats"; +import { supports1MContext } from "../ai/models"; + +export const TOKEN_COMPONENT_COLORS = { + cached: "var(--color-token-cached)", + input: "var(--color-token-input)", + output: "var(--color-token-output)", + thinking: "var(--color-thinking-mode)", +} as const; + +export interface TokenSegment { + type: "cached" | "cacheCreate" | "input" | "output" | "reasoning"; + tokens: number; + percentage: number; + color: string; +} + +export interface TokenMeterData { + segments: TokenSegment[]; + totalTokens: number; + maxTokens?: number; + totalPercentage: number; +} + +interface SegmentDef { + type: TokenSegment["type"]; + key: keyof ChatUsageDisplay; + color: string; + label: string; +} + +const SEGMENT_DEFS: SegmentDef[] = [ + { type: "cached", key: "cached", color: TOKEN_COMPONENT_COLORS.cached, label: "Cache Read" }, + { + type: "cacheCreate", + key: "cacheCreate", + color: TOKEN_COMPONENT_COLORS.cached, + label: "Cache Create", + }, + { type: "input", key: "input", color: TOKEN_COMPONENT_COLORS.input, label: "Input" }, + { type: "output", key: "output", color: TOKEN_COMPONENT_COLORS.output, label: "Output" }, + { + type: "reasoning", + key: "reasoning", + color: TOKEN_COMPONENT_COLORS.thinking, + label: "Thinking", + }, +]; + +/** + * Calculate token meter data. When verticalProportions is true, segments are sized + * proportionally to the request (e.g., 50% cached, 30% input) rather than context window. + */ +export function calculateTokenMeterData( + usage: ChatUsageDisplay | undefined, + model: string, + use1M: boolean, + verticalProportions = false +): TokenMeterData { + if (!usage) return { segments: [], totalTokens: 0, totalPercentage: 0 }; + + const modelStats = getModelStats(model); + const maxTokens = use1M && supports1MContext(model) ? 1_000_000 : modelStats?.max_input_tokens; + + const totalUsed = + usage.input.tokens + + usage.cached.tokens + + usage.cacheCreate.tokens + + usage.output.tokens + + usage.reasoning.tokens; + + const toPercentage = (tokens: number) => { + if (verticalProportions) { + return totalUsed > 0 ? (tokens / totalUsed) * 100 : 0; + } + return maxTokens ? (tokens / maxTokens) * 100 : totalUsed > 0 ? (tokens / totalUsed) * 100 : 0; + }; + + const segments = SEGMENT_DEFS.filter((def) => usage[def.key].tokens > 0).map((def) => ({ + type: def.type, + tokens: usage[def.key].tokens, + percentage: toPercentage(usage[def.key].tokens), + color: def.color, + })); + + const contextPercentage = maxTokens ? (totalUsed / maxTokens) * 100 : 100; + + return { + segments, + totalTokens: totalUsed, + maxTokens, + totalPercentage: verticalProportions + ? maxTokens + ? (totalUsed / maxTokens) * 100 + : 0 + : contextPercentage, + }; +} + +export function formatTokens(tokens: number): string { + return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : tokens.toLocaleString(); +} + +export function getSegmentLabel(type: TokenSegment["type"]): string { + return SEGMENT_DEFS.find((def) => def.type === type)?.label ?? type; +}