@@ -260,7 +260,12 @@ const StatsTabComponent: React.FC<StatsTabProps> = ({ workspaceId }) => {
260260 }
261261
262262 // Get per-model breakdown (works for both session and last-request)
263- const modelBreakdown : ModelBreakdownEntry [ ] = ( ( ) => {
263+ const modelBreakdownData : {
264+ /** Per-model+mode entries (no consolidation; keys may be model:mode) */
265+ byKey : ModelBreakdownEntry [ ] ;
266+ /** Consolidated per-model entries (mode ignored) */
267+ byModel : ModelBreakdownEntry [ ] ;
268+ } = ( ( ) => {
264269 if ( viewMode === "session" ) {
265270 // Session view: aggregate from all completed streams + active
266271 interface BreakdownEntry {
@@ -335,9 +340,11 @@ const StatsTabComponent: React.FC<StatsTabProps> = ({ workspaceId }) => {
335340 breakdown [ activeKey ] = existing ;
336341 }
337342
338- // Convert to display format
339- return Object . entries ( breakdown ) . map ( ( [ key , stats ] ) => {
340- const { model, mode } = parseStatsKey ( key ) ;
343+ const toModelBreakdownEntry = (
344+ model : string ,
345+ stats : BreakdownEntry ,
346+ mode ?: "plan" | "exec"
347+ ) : ModelBreakdownEntry => {
341348 const modelTime = Math . max ( 0 , stats . totalDuration - stats . toolExecutionMs ) ;
342349 const avgTtft = stats . ttftCount > 0 ? stats . ttftSum / stats . ttftCount : null ;
343350 const tokensPerSec = calculateAverageTPS (
@@ -354,6 +361,7 @@ const StatsTabComponent: React.FC<StatsTabProps> = ({ workspaceId }) => {
354361 stats . responseCount > 0 && stats . totalReasoningTokens > 0
355362 ? Math . round ( stats . totalReasoningTokens / stats . responseCount )
356363 : null ;
364+
357365 return {
358366 model,
359367 displayName : getModelDisplayName ( model ) ,
@@ -367,68 +375,117 @@ const StatsTabComponent: React.FC<StatsTabProps> = ({ workspaceId }) => {
367375 tokensPerSec,
368376 avgTokensPerMsg,
369377 avgReasoningPerMsg,
370- mode : mode ?? stats . mode ,
378+ mode,
371379 } ;
380+ } ;
381+
382+ const byKey = Object . entries ( breakdown ) . map ( ( [ key , stats ] ) => {
383+ const { model, mode } = parseStatsKey ( key ) ;
384+ return toModelBreakdownEntry ( model , stats , mode ?? stats . mode ) ;
372385 } ) ;
373- } else {
374- // Last Request view: show single model from the last/active request
375- if ( ! timingStats ) return [ ] ;
376-
377- const elapsed = timingStats . isActive
378- ? now - timingStats . startTime
379- : timingStats . endTime ! - timingStats . startTime ;
380- const modelTime = Math . max ( 0 , elapsed - timingStats . toolExecutionMs ) ;
381- const ttft =
382- timingStats . firstTokenTime !== null
383- ? timingStats . firstTokenTime - timingStats . startTime
384- : null ;
385-
386- // For active streams: use live token data
387- // For completed: use stored token counts
388- const outputTokens = timingStats . isActive
389- ? ( timingStats . liveTokenCount ?? 0 )
390- : ( timingStats . outputTokens ?? 0 ) ;
391- const reasoningTokens = timingStats . reasoningTokens ?? 0 ;
392-
393- // For active streams: streaming time excludes tool execution
394- // For completed: use stored streamingMs (already excludes tools)
395- const rawStreamingMs = timingStats . isActive
396- ? timingStats . firstTokenTime !== null
397- ? now - timingStats . firstTokenTime
398- : 0
399- : ( timingStats . streamingMs ?? 0 ) ;
400- const streamingMs = timingStats . isActive
401- ? Math . max ( 0 , rawStreamingMs - timingStats . toolExecutionMs )
402- : rawStreamingMs ;
403-
404- // Calculate TPS with fallback for old data
405- const tokensPerSec = calculateAverageTPS (
406- streamingMs ,
407- modelTime ,
408- outputTokens ,
409- timingStats . isActive ? ( timingStats . liveTPS ?? null ) : null
410- ) ;
411-
412- return [
413- {
414- model : timingStats . model ,
415- displayName : getModelDisplayName ( timingStats . model ) ,
416- totalDuration : elapsed ,
417- toolExecutionMs : timingStats . toolExecutionMs ,
418- modelTime,
419- avgTtft : ttft ,
420- responseCount : 1 ,
421- totalOutputTokens : outputTokens ,
422- totalReasoningTokens : reasoningTokens ,
423- tokensPerSec,
424- avgTokensPerMsg : outputTokens > 0 ? outputTokens : null ,
425- avgReasoningPerMsg : reasoningTokens > 0 ? reasoningTokens : null ,
426- mode : timingStats . mode ,
427- } ,
428- ] ;
386+
387+ // Consolidate by model when not splitting by mode
388+ const consolidated : Record < string , BreakdownEntry > = { } ;
389+ for ( const [ key , stats ] of Object . entries ( breakdown ) ) {
390+ const { model } = parseStatsKey ( key ) ;
391+ const existing = consolidated [ model ] ?? {
392+ totalDuration : 0 ,
393+ toolExecutionMs : 0 ,
394+ streamingMs : 0 ,
395+ responseCount : 0 ,
396+ totalOutputTokens : 0 ,
397+ totalReasoningTokens : 0 ,
398+ ttftSum : 0 ,
399+ ttftCount : 0 ,
400+ liveTPS : null ,
401+ liveTokenCount : 0 ,
402+ } ;
403+
404+ existing . totalDuration += stats . totalDuration ;
405+ existing . toolExecutionMs += stats . toolExecutionMs ;
406+ existing . streamingMs += stats . streamingMs ;
407+ existing . responseCount += stats . responseCount ;
408+ existing . totalOutputTokens += stats . totalOutputTokens ;
409+ existing . totalReasoningTokens += stats . totalReasoningTokens ;
410+ existing . ttftSum += stats . ttftSum ;
411+ existing . ttftCount += stats . ttftCount ;
412+
413+ // Preserve live data if present (only expected for the active stream)
414+ existing . liveTPS = stats . liveTPS ?? existing . liveTPS ;
415+ existing . liveTokenCount += stats . liveTokenCount ;
416+
417+ consolidated [ model ] = existing ;
418+ }
419+
420+ const byModel = Object . entries ( consolidated ) . map ( ( [ model , stats ] ) => {
421+ return toModelBreakdownEntry ( model , stats ) ;
422+ } ) ;
423+
424+ return { byKey, byModel } ;
429425 }
426+
427+ // Last Request view: show single model from the last/active request
428+ if ( ! timingStats ) return { byKey : [ ] , byModel : [ ] } ;
429+
430+ const elapsed = timingStats . isActive
431+ ? now - timingStats . startTime
432+ : timingStats . endTime ! - timingStats . startTime ;
433+ const modelTime = Math . max ( 0 , elapsed - timingStats . toolExecutionMs ) ;
434+ const ttft =
435+ timingStats . firstTokenTime !== null
436+ ? timingStats . firstTokenTime - timingStats . startTime
437+ : null ;
438+
439+ // For active streams: use live token data
440+ // For completed: use stored token counts
441+ const outputTokens = timingStats . isActive
442+ ? ( timingStats . liveTokenCount ?? 0 )
443+ : ( timingStats . outputTokens ?? 0 ) ;
444+ const reasoningTokens = timingStats . reasoningTokens ?? 0 ;
445+
446+ // For active streams: streaming time excludes tool execution
447+ // For completed: use stored streamingMs (already excludes tools)
448+ const rawStreamingMs = timingStats . isActive
449+ ? timingStats . firstTokenTime !== null
450+ ? now - timingStats . firstTokenTime
451+ : 0
452+ : ( timingStats . streamingMs ?? 0 ) ;
453+ const streamingMs = timingStats . isActive
454+ ? Math . max ( 0 , rawStreamingMs - timingStats . toolExecutionMs )
455+ : rawStreamingMs ;
456+
457+ const tokensPerSec = calculateAverageTPS (
458+ streamingMs ,
459+ modelTime ,
460+ outputTokens ,
461+ timingStats . isActive ? ( timingStats . liveTPS ?? null ) : null
462+ ) ;
463+
464+ const entry : ModelBreakdownEntry = {
465+ model : timingStats . model ,
466+ displayName : getModelDisplayName ( timingStats . model ) ,
467+ totalDuration : elapsed ,
468+ toolExecutionMs : timingStats . toolExecutionMs ,
469+ modelTime,
470+ avgTtft : ttft ,
471+ responseCount : 1 ,
472+ totalOutputTokens : outputTokens ,
473+ totalReasoningTokens : reasoningTokens ,
474+ tokensPerSec,
475+ avgTokensPerMsg : outputTokens > 0 ? outputTokens : null ,
476+ avgReasoningPerMsg : reasoningTokens > 0 ? reasoningTokens : null ,
477+ mode : timingStats . mode ,
478+ } ;
479+
480+ return { byKey : [ entry ] , byModel : [ entry ] } ;
430481 } ) ( ) ;
431482
483+ const hasModeData = viewMode === "session" && modelBreakdownData . byKey . some ( ( m ) => m . mode ) ;
484+ const modelBreakdown =
485+ viewMode === "session" && ! showModeBreakdown
486+ ? modelBreakdownData . byModel
487+ : modelBreakdownData . byKey ;
488+
432489 return (
433490 < div className = "text-light font-primary text-[13px] leading-relaxed" >
434491 < div data-testid = "timing-section" className = "mb-6" >
@@ -538,7 +595,7 @@ const StatsTabComponent: React.FC<StatsTabProps> = ({ workspaceId }) => {
538595 < div className = "flex items-center justify-between" >
539596 < span className = "text-foreground font-medium" > By Model</ span >
540597 { /* Only show toggle in session view when we have mode data */ }
541- { viewMode === "session" && modelBreakdown . some ( ( m ) => m . mode ) && (
598+ { viewMode === "session" && hasModeData && (
542599 < label className = "text-muted-light flex cursor-pointer items-center gap-1.5 text-[10px]" >
543600 < input
544601 type = "checkbox"
0 commit comments