Skip to content

Commit 695cd42

Browse files
authored
🤖 feat: add timing stats indicator in workspace sidebar (#1177)
Add a new **Stats** tab in the right sidebar that displays timing statistics for streaming sessions. The tab shows elapsed time, time to first token (TTFT), model inference time, and tool execution duration. ## Features - **Stats tab in right sidebar** - New tab after Review with keyboard shortcut `Cmd/Ctrl+3` - **Session/Last Request toggle** - Switch between aggregate session stats and last request details - **Tab title shows duration** - Like Costs shows "$2.71", Stats shows total session time (e.g., "Stats 12s") - **Aggregated timing stats** - Progress bar + component breakdown table - **Per-model breakdown** - Each model gets its own timing section with mini progress bar - **Live updates** - Timer updates every second during active streams - **Persistent across restarts** - Session timing stats saved to localStorage ## Screenshot The Stats tab displays timing information in a format consistent with the Costs tab: ``` ┌─────────────────────────────────────────┐ │ Costs $2.71 Review Stats 12s │ ├─────────────────────────────────────────┤ │ Timing ● [Session] [Last Request] 12s │ │ 1 response │ │ ████████████████████░░░░░ │ │ │ │ Component Duration % │ │ ● Avg. Time to First Token 3.1s — │ │ ● Model Time 9.5s 79% │ │ ● Tool Execution 2.5s 21% │ │ │ │ By Model │ │ Sonnet 4 12s (1 req) │ │ ████████████████████░░░░░ │ │ TTFT: 3.1s Model: 9.5s Tools: 2.5s │ └─────────────────────────────────────────┘ ``` ## Implementation - **StatsTab component** - Displays timing stats with toggle and per-model breakdown - **StreamingMessageAggregator** - Tracks timing data (startTime, firstTokenTime, toolExecutionMs) - **WorkspaceStore** - Exposes `StreamTimingStats` and `SessionTimingStats` via sidebar state - **localStorage persistence** - Session stats persist across app restarts --- _Generated with `mux`_ Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 3cf3d07 commit 695cd42

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+3597
-156
lines changed

.storybook/mocks/orpc.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import type { APIClient } from "@/browser/contexts/API";
77
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
88
import type { ProjectConfig } from "@/node/config";
9-
import type { WorkspaceChatMessage, ProvidersConfigMap } from "@/common/orpc/types";
9+
import type {
10+
WorkspaceChatMessage,
11+
ProvidersConfigMap,
12+
WorkspaceStatsSnapshot,
13+
} from "@/common/orpc/types";
1014
import type { ChatStats } from "@/common/types/chatStats";
1115
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
1216
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
@@ -69,6 +73,8 @@ export interface MockORPCClientOptions {
6973
}>
7074
>;
7175
/** Session usage data per workspace (for Costs tab) */
76+
workspaceStatsSnapshots?: Map<string, WorkspaceStatsSnapshot>;
77+
statsTabVariant?: "control" | "stats";
7278
sessionUsage?: Map<string, MockSessionUsage>;
7379
/** MCP server configuration per project */
7480
mcpServers?: Map<
@@ -112,11 +118,27 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
112118
onProjectRemove,
113119
backgroundProcesses = new Map(),
114120
sessionUsage = new Map(),
121+
workspaceStatsSnapshots = new Map<string, WorkspaceStatsSnapshot>(),
122+
statsTabVariant = "control",
115123
mcpServers = new Map(),
116124
mcpOverrides = new Map(),
117125
mcpTestResults = new Map(),
118126
} = options;
119127

128+
// Feature flags
129+
let statsTabOverride: "default" | "on" | "off" = "default";
130+
131+
const getStatsTabState = () => {
132+
const enabled =
133+
statsTabOverride === "on"
134+
? true
135+
: statsTabOverride === "off"
136+
? false
137+
: statsTabVariant === "stats";
138+
139+
return { enabled, variant: statsTabVariant, override: statsTabOverride } as const;
140+
};
141+
120142
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
121143

122144
const mockStats: ChatStats = {
@@ -135,6 +157,16 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
135157
_input.texts.map(() => 0),
136158
calculateStats: async () => mockStats,
137159
},
160+
features: {
161+
getStatsTabState: async () => getStatsTabState(),
162+
setStatsTabOverride: async (input: { override: "default" | "on" | "off" }) => {
163+
statsTabOverride = input.override;
164+
return getStatsTabState();
165+
},
166+
},
167+
telemetry: {
168+
track: async () => undefined,
169+
},
138170
server: {
139171
getLaunchProject: async () => null,
140172
getSshHost: async () => null,
@@ -250,12 +282,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
250282
},
251283
onMetadata: async function* () {
252284
// Empty generator - no metadata updates in mock
253-
await new Promise(() => {}); // Never resolves, keeps stream open
285+
return;
254286
},
255287
activity: {
256288
list: async () => ({}),
257289
subscribe: async function* () {
258-
await new Promise(() => {}); // Never resolves
290+
return;
259291
},
260292
},
261293
backgroundBashes: {
@@ -271,6 +303,18 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
271303
terminate: async () => ({ success: true, data: undefined }),
272304
sendToBackground: async () => ({ success: true, data: undefined }),
273305
},
306+
stats: {
307+
subscribe: async function* (input: { workspaceId: string }) {
308+
const snapshot = workspaceStatsSnapshots.get(input.workspaceId);
309+
if (snapshot) {
310+
yield snapshot;
311+
}
312+
},
313+
clear: async (input: { workspaceId: string }) => {
314+
workspaceStatsSnapshots.delete(input.workspaceId);
315+
return { success: true, data: undefined };
316+
},
317+
},
274318
getSessionUsage: async (input: { workspaceId: string }) => sessionUsage.get(input.workspaceId),
275319
mcp: {
276320
get: async (input: { workspaceId: string }) => mcpOverrides.get(input.workspaceId) ?? {},

src/browser/App.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import { SplashScreenProvider } from "./components/splashScreens/SplashScreenPro
5555
import { TutorialProvider } from "./contexts/TutorialContext";
5656
import { ConnectionStatusIndicator } from "./components/ConnectionStatusIndicator";
5757
import { TooltipProvider } from "./components/ui/tooltip";
58+
import { useFeatureFlags } from "./contexts/FeatureFlagsContext";
59+
import { FeatureFlagsProvider } from "./contexts/FeatureFlagsContext";
5860
import { ExperimentsProvider } from "./contexts/ExperimentsContext";
5961
import { getWorkspaceSidebarKey } from "./utils/workspace";
6062

@@ -131,6 +133,11 @@ function AppInner() {
131133
// Get workspace store for command palette
132134
const workspaceStore = useWorkspaceStoreRaw();
133135

136+
const { statsTabState } = useFeatureFlags();
137+
useEffect(() => {
138+
workspaceStore.setStatsEnabled(Boolean(statsTabState?.enabled));
139+
}, [workspaceStore, statsTabState?.enabled]);
140+
134141
// Track telemetry when workspace selection changes
135142
const prevWorkspaceRef = useRef<WorkspaceSelection | null>(null);
136143
useEffect(() => {
@@ -449,6 +456,7 @@ function AppInner() {
449456
onToggleTheme: toggleTheme,
450457
onSetTheme: setThemePreference,
451458
onOpenSettings: openSettings,
459+
onClearTimingStats: (workspaceId: string) => workspaceStore.clearTimingStats(workspaceId),
452460
api,
453461
};
454462

@@ -761,17 +769,19 @@ function App() {
761769
return (
762770
<ThemeProvider>
763771
<ExperimentsProvider>
764-
<TooltipProvider delayDuration={200}>
765-
<SettingsProvider>
766-
<SplashScreenProvider>
767-
<TutorialProvider>
768-
<CommandRegistryProvider>
769-
<AppInner />
770-
</CommandRegistryProvider>
771-
</TutorialProvider>
772-
</SplashScreenProvider>
773-
</SettingsProvider>
774-
</TooltipProvider>
772+
<FeatureFlagsProvider>
773+
<TooltipProvider delayDuration={200}>
774+
<SettingsProvider>
775+
<SplashScreenProvider>
776+
<TutorialProvider>
777+
<CommandRegistryProvider>
778+
<AppInner />
779+
</CommandRegistryProvider>
780+
</TutorialProvider>
781+
</SplashScreenProvider>
782+
</SettingsProvider>
783+
</TooltipProvider>
784+
</FeatureFlagsProvider>
775785
</ExperimentsProvider>
776786
</ThemeProvider>
777787
);

src/browser/components/AIView.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ import { useAutoScroll } from "@/browser/hooks/useAutoScroll";
4141
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
4242
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";
4343
import { usePersistedState } from "@/browser/hooks/usePersistedState";
44+
import { useFeatureFlags } from "@/browser/contexts/FeatureFlagsContext";
4445
import { useThinking } from "@/browser/contexts/ThinkingContext";
4546
import {
4647
useWorkspaceState,
4748
useWorkspaceAggregator,
4849
useWorkspaceUsage,
50+
useWorkspaceStatsSnapshot,
4951
} from "@/browser/stores/WorkspaceStore";
5052
import { WorkspaceHeader } from "./WorkspaceHeader";
5153
import { getModelName } from "@/common/utils/ai/models";
@@ -106,7 +108,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
106108

107109
// Resizable RightSidebar width - separate hooks per tab for independent persistence
108110
const costsSidebar = useResizableSidebar({
109-
enabled: selectedRightTab === "costs",
111+
// Costs + Stats share the same resizable width persistence
112+
enabled: selectedRightTab === "costs" || selectedRightTab === "stats",
110113
defaultWidth: 300,
111114
minWidth: 300,
112115
maxWidth: 1200,
@@ -127,6 +130,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
127130
const startResize =
128131
selectedRightTab === "review" ? reviewSidebar.startResize : costsSidebar.startResize;
129132

133+
const statsSnapshot = useWorkspaceStatsSnapshot(workspaceId);
134+
const { statsTabState } = useFeatureFlags();
135+
const statsEnabled = Boolean(statsTabState?.enabled);
130136
const workspaceState = useWorkspaceState(workspaceId);
131137
const aggregator = useWorkspaceAggregator(workspaceId);
132138
const workspaceUsage = useWorkspaceUsage(workspaceId);
@@ -705,14 +711,18 @@ const AIViewInner: React.FC<AIViewProps> = ({
705711
awaitingUserQuestion
706712
? undefined
707713
: activeStreamMessageId
708-
? aggregator?.getStreamingTokenCount(activeStreamMessageId)
714+
? statsEnabled && statsSnapshot?.active?.messageId === activeStreamMessageId
715+
? statsSnapshot.active.liveTokenCount
716+
: aggregator?.getStreamingTokenCount(activeStreamMessageId)
709717
: undefined
710718
}
711719
tps={
712720
awaitingUserQuestion
713721
? undefined
714722
: activeStreamMessageId
715-
? aggregator?.getStreamingTPS(activeStreamMessageId)
723+
? statsEnabled && statsSnapshot?.active?.messageId === activeStreamMessageId
724+
? statsSnapshot.active.liveTPS
725+
: aggregator?.getStreamingTPS(activeStreamMessageId)
716726
: undefined
717727
}
718728
/>

0 commit comments

Comments
 (0)