@@ -16,6 +16,7 @@ import { StatsQuery as StatsQuerySchema } from "@roo-code/types"
1616import type { ClineProvider } from "./ClineProvider"
1717import type { UsageStatsService , JsonExport } from "../../services/stats"
1818import { StatsServiceError } from "../../services/stats"
19+ import { getEffectiveCost } from "../../services/stats/costRecalculation"
1920import { resolveDefaultSaveUri , saveLastExportPath } from "../../utils/export"
2021import { readTaskMessages } from "../task-persistence/taskMessages"
2122
@@ -453,10 +454,63 @@ async function deriveSessionTitle(taskId: string, globalStoragePath: string): Pr
453454}
454455
455456/**
456- * Groups usage events by `taskId` and produces a {@link SessionSummary} for
457- * each group. The summary uses the first event's model/provider/mode as
458- * representative values (a session may span multiple models, but the first
459- * event is a reasonable proxy for display purposes).
457+ * Resolves the root task ID for a usage event.
458+ *
459+ * Feature 2: Sessions should be grouped by conversation session (root task),
460+ * not by individual subtask. A subtask has a `parentTaskId` pointing to its
461+ * parent. By following the parent chain, we can group all subtasks under
462+ * their root conversation session.
463+ *
464+ * Since the event only carries its immediate `parentTaskId` (not the full
465+ * chain), we build a parent→children map from the event set and walk up
466+ * the chain. If an event has no `parentTaskId`, it IS the root.
467+ *
468+ * @param event The usage event to resolve.
469+ * @param parentMap Map of taskId → parentTaskId (built from the event set).
470+ * @returns The root task ID for grouping.
471+ */
472+ function resolveRootTaskId ( event : UsageEventV1 , parentMap : Map < string , string | undefined > ) : string {
473+ let current = event . taskId
474+ const visited = new Set < string > ( ) // Guard against cycles
475+
476+ while ( ! visited . has ( current ) ) {
477+ visited . add ( current )
478+ const parent = parentMap . get ( current )
479+ if ( ! parent ) break // No parent → this is the root
480+ current = parent
481+ }
482+
483+ return current
484+ }
485+
486+ /**
487+ * Builds a map of taskId → parentTaskId from a set of usage events.
488+ * This allows resolving the root task for any event in the set.
489+ */
490+ function buildParentMap ( events : UsageEventV1 [ ] ) : Map < string , string | undefined > {
491+ const parentMap = new Map < string , string | undefined > ( )
492+ for ( const event of events ) {
493+ if ( ! parentMap . has ( event . taskId ) ) {
494+ parentMap . set ( event . taskId , event . parentTaskId )
495+ }
496+ }
497+ return parentMap
498+ }
499+
500+ /**
501+ * Groups usage events by their root conversation session and produces a
502+ * {@link SessionSummary} for each group.
503+ *
504+ * Feature 2: Events are grouped by root task ID (following `parentTaskId`
505+ * chains) so that subtasks appear under their parent conversation session.
506+ * If an event has no `parentTaskId`, it is its own root.
507+ *
508+ * Feature 1: Missing `costUsd` values are computed on-the-fly using the
509+ * model's pricing info. The NDJSON store is never modified.
510+ *
511+ * The summary uses the first event's model/provider/mode as representative
512+ * values (a session may span multiple models, but the first event is a
513+ * reasonable proxy for display purposes).
460514 *
461515 * @param events Filtered usage events (already scoped to the requested time
462516 * range and `includeCancelled` policy).
@@ -466,14 +520,18 @@ async function buildSessionSummaries(
466520 events : UsageEventV1 [ ] ,
467521 globalStoragePath : string ,
468522) : Promise < SessionSummary [ ] > {
469- // Group events by taskId, preserving insertion order for determinism.
523+ // Feature 2: Build parent map and group by root task ID.
524+ const parentMap = buildParentMap ( events )
525+
526+ // Group events by root taskId, preserving insertion order for determinism.
470527 const groups = new Map < string , UsageEventV1 [ ] > ( )
471528 for ( const event of events ) {
472- const list = groups . get ( event . taskId )
529+ const rootTaskId = resolveRootTaskId ( event , parentMap )
530+ const list = groups . get ( rootTaskId )
473531 if ( list ) {
474532 list . push ( event )
475533 } else {
476- groups . set ( event . taskId , [ event ] )
534+ groups . set ( rootTaskId , [ event ] )
477535 }
478536 }
479537
@@ -491,11 +549,12 @@ async function buildSessionSummaries(
491549 const last = sorted [ sorted . length - 1 ]
492550
493551 // Aggregate totals across all events in the task.
552+ // Feature 1: Use getEffectiveCost to compute missing costs on-the-fly.
494553 let totalTokens = 0
495554 let totalCost = 0
496555 for ( const ev of sorted ) {
497556 totalTokens += ev . usage . totalTokens ?. value ?? 0
498- totalCost += ev . usage . costUsd ?. value ?? 0
557+ totalCost += getEffectiveCost ( ev )
499558 }
500559
501560 const title = await deriveSessionTitle ( taskId , globalStoragePath )
@@ -635,7 +694,8 @@ function mapEventToApiCall(event: UsageEventV1, index: number): APICallRecord {
635694 cacheReadTokens : event . usage . cacheReadTokens ?. value ?? 0 ,
636695 cacheWriteTokens : event . usage . cacheWriteTokens ?. value ?? 0 ,
637696 reasoningTokens : event . usage . reasoningTokens ?. value ?? 0 ,
638- costUsd : event . usage . costUsd ?. value ?? 0 ,
697+ // Feature 1: Compute missing cost on-the-fly from model pricing.
698+ costUsd : getEffectiveCost ( event ) ,
639699 status : event . status ,
640700 model : event . model ,
641701 }
@@ -667,11 +727,12 @@ async function buildSessionDetail(
667727 const last = sorted [ sorted . length - 1 ]
668728
669729 // Aggregate totals across all events in the task.
730+ // Feature 1: Use getEffectiveCost to compute missing costs on-the-fly.
670731 let totalTokens = 0
671732 let totalCost = 0
672733 for ( const ev of sorted ) {
673734 totalTokens += ev . usage . totalTokens ?. value ?? 0
674- totalCost += ev . usage . costUsd ?. value ?? 0
735+ totalCost += getEffectiveCost ( ev )
675736 }
676737
677738 const title = await deriveSessionTitle ( taskId , globalStoragePath )
@@ -760,8 +821,12 @@ export async function handleGetDashboardSessionDetail(
760821 const exportData = await service . exportStats ( allQuery , "json" )
761822 const allEvents : UsageEventV1 [ ] = ( exportData as JsonExport ) . events ?? [ ]
762823
763- // Filter to the requested task.
764- const taskEvents = allEvents . filter ( ( ev ) => ev . taskId === taskId )
824+ // Feature 2: Filter to the requested root task AND its subtasks.
825+ // The session list groups events by root task ID, so clicking a
826+ // session row passes the root task ID. We need to include events
827+ // from all subtasks whose root resolves to this taskId.
828+ const parentMap = buildParentMap ( allEvents )
829+ const taskEvents = allEvents . filter ( ( ev ) => resolveRootTaskId ( ev , parentMap ) === taskId )
765830
766831 if ( taskEvents . length === 0 ) {
767832 // No events for this task — return an empty detail rather than an
0 commit comments