Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0cb1d64
πŸ€– Add fallback for reasoning tokens and reorganize CostsTab
ammar-agent Oct 16, 2025
e1c9063
πŸ€– Fix CostsTab re-render storm with two-store architecture
ammar-agent Oct 16, 2025
3f780d2
Fix race condition: Show loading state while calculating tokens
ammar-agent Oct 16, 2025
bb6d2ae
πŸ“ Document usage metadata persistence architecture
ammar-agent Oct 16, 2025
3c8a8c6
Fix CostsTab blocking architecture - render sections independently
ammar-agent Oct 16, 2025
41d832a
Fix CostsTab blocking architecture - render sections independently
ammar-agent Oct 16, 2025
1c08ec3
πŸ€– Fix consumer calculation spam and lazy loading
ammar-agent Oct 16, 2025
c26ab42
πŸ€– Extract consumer calculation logic and fix lazy loading
ammar-agent Oct 16, 2025
6acd98d
πŸ€– Fix consumer calculation cancellations and lazy loading
ammar-agent Oct 16, 2025
80809c2
πŸ€– Eliminate flash of 'No consumer data available'
ammar-agent Oct 16, 2025
45f40ef
πŸ€– Memoize CostsTab, ConsumerBreakdown, and ChatMetaSidebar
ammar-agent Oct 16, 2025
d6b701e
πŸ€– Add missing React.memo export for ChatMetaSidebar
ammar-agent Oct 16, 2025
f6fb6c5
Fix lint errors: remove unused imports, use readonly, fix formatting
ammar-agent Oct 16, 2025
0ec6a79
Queue follow-up calculation when events occur during pending calculation
ammar-agent Oct 16, 2025
9acdb2b
Move cost display to right side of bar
ammar-agent Oct 16, 2025
45e5e22
Move Cost header inline with cost value for better space utilization
ammar-agent Oct 16, 2025
2bd8706
Add 8px margin-bottom to cost header for better spacing
ammar-agent Oct 16, 2025
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
276 changes: 135 additions & 141 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
mergeConsecutiveStreamErrors,
} from "@/utils/messages/messageUtils";
import { hasInterruptedStream } from "@/utils/messages/retryEligibility";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThinkingProvider } from "@/contexts/ThinkingContext";
import { ModeProvider } from "@/contexts/ModeContext";
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
Expand Down Expand Up @@ -379,8 +378,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
}

// Extract state from workspace state
const { messages, canInterrupt, isCompacting, loading, cmuxMessages, currentModel } =
workspaceState;
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;

// Get active stream message ID for token counting
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
Expand Down Expand Up @@ -426,147 +424,143 @@ const AIViewInner: React.FC<AIViewProps> = ({
}

return (
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel ?? "unknown"}>
<ViewContainer className={className}>
<ChatArea ref={chatAreaRef}>
<ViewHeader>
<WorkspaceTitle>
<StatusIndicator
streaming={canInterrupt}
title={
canInterrupt && currentModel ? `${getModelName(currentModel)} streaming` : "Idle"
<ViewContainer className={className}>
<ChatArea ref={chatAreaRef}>
<ViewHeader>
<WorkspaceTitle>
<StatusIndicator
streaming={canInterrupt}
title={
canInterrupt && currentModel ? `${getModelName(currentModel)} streaming` : "Idle"
}
/>
<GitStatusIndicator
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="bottom"
/>
{projectName} / {branch}
<WorkspacePath>{namedWorkspacePath}</WorkspacePath>
<TooltipWrapper inline>
<TerminalIconButton onClick={handleOpenTerminal}>
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
</svg>
</TerminalIconButton>
<Tooltip className="tooltip" position="bottom" align="center">
Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
</Tooltip>
</TooltipWrapper>
</WorkspaceTitle>
</ViewHeader>

<OutputContainer>
<OutputContent
ref={contentRef}
onWheel={markUserInteraction}
onTouchMove={markUserInteraction}
onScroll={handleScroll}
role="log"
aria-live={canInterrupt ? "polite" : "off"}
aria-busy={canInterrupt}
aria-label="Conversation transcript"
tabIndex={0}
>
{mergedMessages.length === 0 ? (
<EmptyState>
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
</EmptyState>
) : (
<>
{mergedMessages.map((msg) => {
const isAtCutoff =
editCutoffHistoryId !== undefined &&
msg.type !== "history-hidden" &&
msg.historyId === editCutoffHistoryId;

return (
<React.Fragment key={msg.id}>
<div
data-message-id={msg.type !== "history-hidden" ? msg.historyId : undefined}
>
<MessageRenderer
message={msg}
onEditUserMessage={handleEditUserMessage}
workspaceId={workspaceId}
isCompacting={isCompacting}
/>
</div>
{isAtCutoff && (
<EditBarrier>
⚠️ Messages below this line will be removed when you submit the edit
</EditBarrier>
)}
{shouldShowInterruptedBarrier(msg) && <InterruptedBarrier />}
</React.Fragment>
);
})}
{/* Show RetryBarrier after the last message if needed */}
{showRetryBarrier && (
<RetryBarrier
workspaceId={workspaceId}
autoRetry={autoRetry}
onStopAutoRetry={() => setAutoRetry(false)}
onResetAutoRetry={() => setAutoRetry(true)}
/>
)}
</>
)}
<PinnedTodoList workspaceId={workspaceId} />
{canInterrupt && (
<StreamingBarrier
statusText={
isCompacting
? currentModel
? `${getModelName(currentModel)} compacting...`
: "compacting..."
: currentModel
? `${getModelName(currentModel)} streaming...`
: "streaming..."
}
cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`}
tokenCount={
activeStreamMessageId
? aggregator.getStreamingTokenCount(activeStreamMessageId)
: undefined
}
tps={
activeStreamMessageId
? aggregator.getStreamingTPS(activeStreamMessageId)
: undefined
}
/>
<GitStatusIndicator
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="bottom"
/>
{projectName} / {branch}
<WorkspacePath>{namedWorkspacePath}</WorkspacePath>
<TooltipWrapper inline>
<TerminalIconButton onClick={handleOpenTerminal}>
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
</svg>
</TerminalIconButton>
<Tooltip className="tooltip" position="bottom" align="center">
Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
</Tooltip>
</TooltipWrapper>
</WorkspaceTitle>
</ViewHeader>

<OutputContainer>
<OutputContent
ref={contentRef}
onWheel={markUserInteraction}
onTouchMove={markUserInteraction}
onScroll={handleScroll}
role="log"
aria-live={canInterrupt ? "polite" : "off"}
aria-busy={canInterrupt}
aria-label="Conversation transcript"
tabIndex={0}
>
{mergedMessages.length === 0 ? (
<EmptyState>
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
</EmptyState>
) : (
<>
{mergedMessages.map((msg) => {
const isAtCutoff =
editCutoffHistoryId !== undefined &&
msg.type !== "history-hidden" &&
msg.historyId === editCutoffHistoryId;

return (
<React.Fragment key={msg.id}>
<div
data-message-id={
msg.type !== "history-hidden" ? msg.historyId : undefined
}
>
<MessageRenderer
message={msg}
onEditUserMessage={handleEditUserMessage}
workspaceId={workspaceId}
isCompacting={isCompacting}
/>
</div>
{isAtCutoff && (
<EditBarrier>
⚠️ Messages below this line will be removed when you submit the edit
</EditBarrier>
)}
{shouldShowInterruptedBarrier(msg) && <InterruptedBarrier />}
</React.Fragment>
);
})}
{/* Show RetryBarrier after the last message if needed */}
{showRetryBarrier && (
<RetryBarrier
workspaceId={workspaceId}
autoRetry={autoRetry}
onStopAutoRetry={() => setAutoRetry(false)}
onResetAutoRetry={() => setAutoRetry(true)}
/>
)}
</>
)}
<PinnedTodoList workspaceId={workspaceId} />
{canInterrupt && (
<StreamingBarrier
statusText={
isCompacting
? currentModel
? `${getModelName(currentModel)} compacting...`
: "compacting..."
: currentModel
? `${getModelName(currentModel)} streaming...`
: "streaming..."
}
cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`}
tokenCount={
activeStreamMessageId
? aggregator.getStreamingTokenCount(activeStreamMessageId)
: undefined
}
tps={
activeStreamMessageId
? aggregator.getStreamingTPS(activeStreamMessageId)
: undefined
}
/>
)}
</OutputContent>
{!autoScroll && (
<JumpToBottomIndicator onClick={jumpToBottom} type="button">
Press {formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)} to jump to bottom
</JumpToBottomIndicator>
)}
</OutputContainer>

<ChatInput
workspaceId={workspaceId}
onMessageSent={handleMessageSent}
onTruncateHistory={handleClearHistory}
onProviderConfig={handleProviderConfig}
disabled={!projectName || !branch}
isCompacting={isCompacting}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
onEditLastUserMessage={handleEditLastUserMessage}
canInterrupt={canInterrupt}
onReady={handleChatInputReady}
/>
</ChatArea>

<ChatMetaSidebar workspaceId={workspaceId} chatAreaRef={chatAreaRef} />
</ViewContainer>
</ChatProvider>
</OutputContent>
{!autoScroll && (
<JumpToBottomIndicator onClick={jumpToBottom} type="button">
Press {formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)} to jump to bottom
</JumpToBottomIndicator>
)}
</OutputContainer>

<ChatInput
workspaceId={workspaceId}
onMessageSent={handleMessageSent}
onTruncateHistory={handleClearHistory}
onProviderConfig={handleProviderConfig}
disabled={!projectName || !branch}
isCompacting={isCompacting}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
onEditLastUserMessage={handleEditLastUserMessage}
canInterrupt={canInterrupt}
onReady={handleChatInputReady}
/>
</ChatArea>

<ChatMetaSidebar workspaceId={workspaceId} chatAreaRef={chatAreaRef} />
</ViewContainer>
);
};

Expand Down
22 changes: 14 additions & 8 deletions src/components/ChatMetaSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import styled from "@emotion/styled";
import { usePersistedState } from "@/hooks/usePersistedState";
import { useChatContext } from "@/contexts/ChatContext";
import { useWorkspaceUsage } from "@/stores/WorkspaceStore";
import { use1MContext } from "@/hooks/use1MContext";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { CostsTab } from "./ChatMetaSidebar/CostsTab";
Expand Down Expand Up @@ -87,13 +87,13 @@ interface ChatMetaSidebarProps {
chatAreaRef: React.RefObject<HTMLDivElement>;
}

export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
const ChatMetaSidebarComponent: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
const [selectedTab, setSelectedTab] = usePersistedState<TabType>(
`chat-meta-sidebar-tab:${workspaceId}`,
"costs"
);

const { stats } = useChatContext();
const usage = useWorkspaceUsage(workspaceId);
const [use1M] = use1MContext();
const chatAreaSize = useResizeObserver(chatAreaRef);

Expand All @@ -103,14 +103,16 @@ export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId, c
const costsPanelId = `${baseId}-panel-costs`;
const toolsPanelId = `${baseId}-panel-tools`;

const lastUsage = stats?.usageHistory[stats.usageHistory.length - 1];
const lastUsage = usage?.usageHistory[usage.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)
// Get model from last usage
const model = lastUsage?.model ?? "unknown";
return lastUsage
? calculateTokenMeterData(lastUsage, model, use1M, true)
: { segments: [], totalTokens: 0, totalPercentage: 0 };
}, [lastUsage, stats, use1M]);
}, [lastUsage, use1M]);

// Calculate if we should show collapsed view with hysteresis
// Strategy: Observe ChatArea width directly (independent of sidebar width)
Expand Down Expand Up @@ -168,7 +170,7 @@ export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId, c
<TabContent>
{selectedTab === "costs" && (
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
<CostsTab />
<CostsTab workspaceId={workspaceId} />
</div>
)}
{selectedTab === "tools" && (
Expand All @@ -184,3 +186,7 @@ export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId, c
</SidebarContainer>
);
};

// Memoize to prevent re-renders when parent (AIView) re-renders during streaming
// Only re-renders when workspaceId or chatAreaRef changes, or internal state updates
export const ChatMetaSidebar = React.memo(ChatMetaSidebarComponent);
Loading