Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`;

Expand Down Expand Up @@ -203,6 +204,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
workspacePath,
className,
}) => {
const chatAreaRef = useRef<HTMLDivElement>(null);

// NEW: Get workspace state from store (only re-renders when THIS workspace changes)
const workspaceState = useWorkspaceState(workspaceId);
const aggregator = useWorkspaceAggregator(workspaceId);
Expand Down Expand Up @@ -346,7 +349,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
if (!workspaceState) {
return (
<ViewContainer className={className}>
<ChatArea>
<ChatArea ref={chatAreaRef}>
<OutputContainer>
<LoadingIndicator>Loading workspace...</LoadingIndicator>
</OutputContainer>
Expand Down Expand Up @@ -405,7 +408,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
return (
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel ?? "unknown"}>
<ViewContainer className={className}>
<ChatArea>
<ChatArea ref={chatAreaRef}>
<ViewHeader>
<WorkspaceTitle>
<StatusIndicator
Expand Down Expand Up @@ -532,7 +535,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
/>
</ChatArea>

<ChatMetaSidebar workspaceId={workspaceId} />
<ChatMetaSidebar workspaceId={workspaceId} chatAreaRef={chatAreaRef} />
</ViewContainer>
</ChatProvider>
);
Expand Down
159 changes: 116 additions & 43 deletions src/components/ChatMetaSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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<SidebarContainerProps>`
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`
Expand Down Expand Up @@ -56,58 +84,103 @@ type TabType = "costs" | "tools";

interface ChatMetaSidebarProps {
workspaceId: string;
chatAreaRef: React.RefObject<HTMLDivElement>;
}

export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId }) => {
export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
const [selectedTab, setSelectedTab] = usePersistedState<TabType>(
`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 (
<SidebarContainer role="complementary" aria-label="Workspace insights">
<TabBar role="tablist" aria-label="Metadata views">
<TabButton
active={selectedTab === "costs"}
onClick={() => setSelectedTab("costs")}
id={costsTabId}
role="tab"
type="button"
aria-selected={selectedTab === "costs"}
aria-controls={costsPanelId}
>
Costs
</TabButton>
<TabButton
active={selectedTab === "tools"}
onClick={() => setSelectedTab("tools")}
id={toolsTabId}
role="tab"
type="button"
aria-selected={selectedTab === "tools"}
aria-controls={toolsPanelId}
>
Tools
</TabButton>
</TabBar>
<TabContent>
{selectedTab === "costs" && (
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
<CostsTab />
</div>
)}
{selectedTab === "tools" && (
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
<ToolsTab />
</div>
)}
</TabContent>
<SidebarContainer
collapsed={showCollapsed}
role="complementary"
aria-label="Workspace insights"
>
<FullView visible={!showCollapsed}>
<TabBar role="tablist" aria-label="Metadata views">
<TabButton
active={selectedTab === "costs"}
onClick={() => setSelectedTab("costs")}
id={costsTabId}
role="tab"
type="button"
aria-selected={selectedTab === "costs"}
aria-controls={costsPanelId}
>
Costs
</TabButton>
<TabButton
active={selectedTab === "tools"}
onClick={() => setSelectedTab("tools")}
id={toolsTabId}
role="tab"
type="button"
aria-selected={selectedTab === "tools"}
aria-controls={toolsPanelId}
>
Tools
</TabButton>
</TabBar>
<TabContent>
{selectedTab === "costs" && (
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
<CostsTab />
</div>
)}
{selectedTab === "tools" && (
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
<ToolsTab />
</div>
)}
</TabContent>
</FullView>
<CollapsedView visible={showCollapsed}>
<VerticalTokenMeter data={verticalMeterData} />
</CollapsedView>
</SidebarContainer>
);
};
27 changes: 10 additions & 17 deletions src/components/ChatMetaSidebar/CostsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SegmentProps>`
height: 100%;
width: ${(props) => props.percentage}%;
Expand All @@ -111,28 +104,28 @@ const VariableSegment = styled.div<SegmentProps>`
const InputSegment = styled.div<SegmentProps>`
height: 100%;
width: ${(props) => props.percentage}%;
background: ${COMPONENT_COLORS.input};
background: ${TOKEN_COMPONENT_COLORS.input};
transition: width 0.3s ease;
`;

const OutputSegment = styled.div<SegmentProps>`
height: 100%;
width: ${(props) => props.percentage}%;
background: ${COMPONENT_COLORS.output};
background: ${TOKEN_COMPONENT_COLORS.output};
transition: width 0.3s ease;
`;

const ThinkingSegment = styled.div<SegmentProps>`
height: 100%;
width: ${(props) => props.percentage}%;
background: ${COMPONENT_COLORS.thinking};
background: ${TOKEN_COMPONENT_COLORS.thinking};
transition: width 0.3s ease;
`;

const CachedSegment = styled.div<SegmentProps>`
height: 100%;
width: ${(props) => props.percentage}%;
background: ${COMPONENT_COLORS.cached};
background: ${TOKEN_COMPONENT_COLORS.cached};
transition: width 0.3s ease;
`;

Expand Down Expand Up @@ -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)
Expand Down
Loading