Skip to content

Commit c4afe5a

Browse files
author
Zoo (VP)
committed
feat(stats): group dashboard tasks under root tasks with expandable subtasks
The Tasks list paged every History task, so subtasks appeared as sibling rows even though each parent row already aggregates its whole subtree (double-counted visually, detached from the summary cards). - Catalog pages root tasks only; bounded-range membership is subtree-based (a root is listed when the root or any descendant was created in range), orphans promote to roots. - DashboardTaskSummary gains childTaskIds; DashboardTaskPage gains childTasks carrying direct children of the page's roots. - Reducer keeps childTasks/subtask upserts out of the visible root order while storing them in the normalized map. - TaskList renders roots; expanding a root with subtasks shows an indented subtask list, and each subtask toggles its own API-call detail. Childless roots expand directly into their detail. - Adds Playwright CT coverage for the expand interaction (jsdom mocks react-virtuoso and cannot exercise it).
1 parent 828ae18 commit c4afe5a

15 files changed

Lines changed: 674 additions & 145 deletions

packages/types/src/__tests__/dashboard-stats-stream.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const validTaskSummary: DashboardTaskSummary = {
7676
model: "claude-sonnet-4-20250514",
7777
provider: "anthropic",
7878
eventCount: 5,
79+
childTaskIds: [],
7980
}
8081

8182
// ── DashboardSessionPageRequest ─────────────────────────────────────────────
@@ -283,6 +284,16 @@ describe("DashboardTaskSummary", () => {
283284
it("should reject a negative event count", () => {
284285
expect(() => DashboardTaskSummary.parse({ ...validTaskSummary, eventCount: -1 })).toThrow()
285286
})
287+
288+
it("should carry direct child task ids", () => {
289+
const result = DashboardTaskSummary.parse({ ...validTaskSummary, childTaskIds: ["child-1", "child-2"] })
290+
expect(result.childTaskIds).toEqual(["child-1", "child-2"])
291+
})
292+
293+
it("should reject a summary missing childTaskIds", () => {
294+
const { childTaskIds: _childTaskIds, ...withoutChildTaskIds } = validTaskSummary
295+
expect(() => DashboardTaskSummary.parse(withoutChildTaskIds)).toThrow()
296+
})
286297
})
287298

288299
describe("DashboardTaskPage", () => {
@@ -300,6 +311,13 @@ describe("DashboardTaskPage", () => {
300311
expect(result.tasks).toHaveLength(1)
301312
})
302313

314+
it("should accept direct children of the page's root tasks", () => {
315+
const child = { ...validTaskSummary, taskId: "child-1", childTaskIds: [] }
316+
const result = DashboardTaskPage.parse({ ...validPage, childTasks: [child] })
317+
expect(result.childTasks).toHaveLength(1)
318+
expect(result.childTasks?.[0]?.taskId).toBe("child-1")
319+
})
320+
303321
it("should reject a negative catalog revision", () => {
304322
expect(() => DashboardTaskPage.parse({ ...validPage, catalogRevision: -1 })).toThrow()
305323
})

packages/types/src/usage-stats.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ export const DashboardTaskSummary = z.object({
294294
model: z.string(),
295295
provider: z.string(),
296296
eventCount: z.number().int().nonnegative(),
297+
/** Direct children in catalog order; empty for childless tasks. */
298+
childTaskIds: z.array(z.string()),
297299
})
298300
export type DashboardTaskSummary = z.infer<typeof DashboardTaskSummary>
299301

@@ -303,7 +305,10 @@ export const DashboardTaskPage = z.object({
303305
requestId: z.string(),
304306
/** Immutable History catalog revision used to produce this page. */
305307
catalogRevision: z.number().int().nonnegative(),
308+
/** Root tasks only, in catalog order. Subtasks appear in `childTasks`. */
306309
tasks: z.array(DashboardTaskSummary),
310+
/** Direct children of this page's root tasks, keyed via their `parentTaskId`. */
311+
childTasks: z.array(DashboardTaskSummary).optional(),
307312
/** Opaque host-issued cursor for the next page. */
308313
cursor: z.string().optional(),
309314
/** Exact catalog size for the current revision. */

src/services/stats/DashboardTaskCatalog.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface DashboardTaskCatalogSnapshot {
1818
childrenByParentId: ReadonlyMap<string, readonly string[]>
1919
ancestorsByTaskId: ReadonlyMap<string, readonly string[]>
2020
orderedTaskIds: readonly string[]
21+
/** Subset of `orderedTaskIds` holding only root tasks (no parent in the catalog). */
22+
orderedRootTaskIds: readonly string[]
2123
}
2224

2325
/** A deterministic page of task IDs from one catalog revision. */
@@ -112,6 +114,10 @@ export class DashboardTaskCatalog implements vscode.Disposable {
112114
return this.snapshot.orderedTaskIds
113115
}
114116

117+
get orderedRootTaskIds(): readonly string[] {
118+
return this.snapshot.orderedRootTaskIds
119+
}
120+
115121
/**
116122
* Contains descendant calculations already requested for this revision. Use
117123
* getDescendantTaskIds() to populate this lazy index.
@@ -162,14 +168,17 @@ export class DashboardTaskCatalog implements vscode.Disposable {
162168
}
163169

164170
/**
171+
* Pages root tasks only (tasks whose parent is absent from the catalog);
172+
* subtasks reach the client through their root's `childTaskIds` instead.
173+
*
165174
* Uses a compound `(ts DESC, id DESC)` cursor. Cursors from older snapshots
166175
* are rejected so pages never combine task catalog revisions.
167176
*
168-
* When `rangeMs` is bounded, membership is filtered on the task's creation
169-
* timestamp (`HistoryItem.ts`) within the half-open `[fromMs, toMs)` range;
170-
* ordering, cursor semantics, and `totalEstimate` (now the filtered count)
171-
* are otherwise unchanged. An absent or unbounded range keeps the legacy
172-
* unfiltered behavior.
177+
* When `rangeMs` is bounded, membership is subtree-based: a root is included
178+
* when the root itself OR any of its descendants was created (HistoryItem.ts)
179+
* within the half-open `[fromMs, toMs)` range. Ordering, cursor semantics,
180+
* and `totalEstimate` (the filtered root count) are otherwise unchanged. An
181+
* absent or unbounded range keeps the legacy unfiltered behavior.
173182
*/
174183
getPage(
175184
cursor?: string,
@@ -178,30 +187,29 @@ export class DashboardTaskCatalog implements vscode.Disposable {
178187
): DashboardTaskCatalogPage {
179188
const pageLimit = normalizePageLimit(limit)
180189
const startIndex = cursor ? this.findPageStartIndex(this.decodeCursor(cursor)) : 0
190+
const orderedRootTaskIds = this.snapshot.orderedRootTaskIds
181191

182192
if (!isStatsQueryRangeBounded(rangeMs)) {
183-
const tasks = this.snapshot.orderedTaskIds.slice(startIndex, startIndex + pageLimit)
193+
const tasks = orderedRootTaskIds.slice(startIndex, startIndex + pageLimit)
184194
const lastTaskId = tasks.at(-1)
185195

186196
return {
187197
tasks: [...tasks],
188198
cursor:
189-
lastTaskId && startIndex + tasks.length < this.snapshot.orderedTaskIds.length
199+
lastTaskId && startIndex + tasks.length < orderedRootTaskIds.length
190200
? this.encodeCursor(lastTaskId)
191201
: undefined,
192-
totalEstimate: this.snapshot.orderedTaskIds.length,
202+
totalEstimate: orderedRootTaskIds.length,
193203
}
194204
}
195205

196-
const orderedTaskIds = this.snapshot.orderedTaskIds
197206
const tasks: string[] = []
198207
let totalEstimate = 0
199208
let hasMore = false
200209

201-
for (let index = 0; index < orderedTaskIds.length; index++) {
202-
const taskId = orderedTaskIds[index]
203-
const item = this.snapshot.byId.get(taskId)!
204-
if (!isWithinStatsQueryRange(rangeMs, item.ts)) {
210+
for (let index = 0; index < orderedRootTaskIds.length; index++) {
211+
const taskId = orderedRootTaskIds[index]
212+
if (!this.isSubtreeWithinRange(rangeMs, taskId)) {
205213
continue
206214
}
207215
totalEstimate += 1
@@ -250,6 +258,25 @@ export class DashboardTaskCatalog implements vscode.Disposable {
250258
}, CATALOG_REBUILD_DEBOUNCE_MS)
251259
}
252260

261+
/**
262+
* Subtree-based range membership for one catalog task: true when the task
263+
* itself or any of its descendants was created within the (bounded) range.
264+
* Used by both paging and summary upserts so membership rules never diverge.
265+
*/
266+
isSubtreeWithinRange(rangeMs: StatsQueryRangeMs | undefined, taskId: string): boolean {
267+
const item = this.snapshot.byId.get(taskId)
268+
if (item && isWithinStatsQueryRange(rangeMs, item.ts)) {
269+
return true
270+
}
271+
for (const descendantId of this.getDescendantTaskIds(taskId)) {
272+
const descendant = this.snapshot.byId.get(descendantId)
273+
if (descendant && isWithinStatsQueryRange(rangeMs, descendant.ts)) {
274+
return true
275+
}
276+
}
277+
return false
278+
}
279+
253280
private createSnapshot(revision: number): DashboardTaskCatalogSnapshot {
254281
const latestItemsById = new Map<string, HistoryItem>()
255282
for (const item of this.source.getAll()) {
@@ -266,6 +293,13 @@ export class DashboardTaskCatalog implements vscode.Disposable {
266293
}
267294

268295
const orderedTaskIds = [...byId.keys()].sort((leftId, rightId) => compareTaskIds(leftId, rightId, byId))
296+
// Root = no parent task, or its parent is absent from the catalog (orphan).
297+
// This mirrors the childrenByParentId link condition below so every
298+
// non-root task is reachable from exactly one root's subtree.
299+
const orderedRootTaskIds = orderedTaskIds.filter((taskId) => {
300+
const item = byId.get(taskId)!
301+
return !item.parentTaskId || !byId.has(item.parentTaskId)
302+
})
269303
const mutableChildrenByParentId = new Map<string, string[]>()
270304
for (const [taskId, item] of byId) {
271305
if (!item.parentTaskId || !byId.has(item.parentTaskId)) {
@@ -297,6 +331,7 @@ export class DashboardTaskCatalog implements vscode.Disposable {
297331
childrenByParentId: new ImmutableMap(childrenByParentId),
298332
ancestorsByTaskId: new ImmutableMap(ancestorsByTaskId),
299333
orderedTaskIds: Object.freeze(orderedTaskIds),
334+
orderedRootTaskIds: Object.freeze(orderedRootTaskIds),
300335
}
301336
return Object.freeze(snapshot)
302337
}
@@ -338,11 +373,12 @@ export class DashboardTaskCatalog implements vscode.Disposable {
338373
`Cursor revision ${cursor.r} does not match catalog revision ${this.snapshot.revision}`,
339374
)
340375
}
341-
const index = this.snapshot.orderedTaskIds.findIndex((taskId) => {
376+
const orderedRootTaskIds = this.snapshot.orderedRootTaskIds
377+
const index = orderedRootTaskIds.findIndex((taskId) => {
342378
const item = this.snapshot.byId.get(taskId)!
343379
return item.ts < cursor.ts || (item.ts === cursor.ts && taskId < cursor.id)
344380
})
345-
return index === -1 ? this.snapshot.orderedTaskIds.length : index
381+
return index === -1 ? orderedRootTaskIds.length : index
346382
}
347383

348384
private encodeCursor(taskId: string): string {

src/services/stats/DashboardTaskProjection.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
import { DashboardTaskCatalog } from "./DashboardTaskCatalog"
1010
import type { TaskUsageRow } from "./UsageStatsDatabase"
1111
import { getEffectiveCost } from "./costRecalculation"
12-
import { isWithinStatsQueryRange, type StatsQueryRangeMs } from "./statsQueryRange"
12+
import { type StatsQueryRangeMs } from "./statsQueryRange"
1313

1414
/** Error codes emitted by the History-first Dashboard task projection. */
1515
export type DashboardTaskProjectionErrorCode = "DASHBOARD_TASK_PROJECTION/computeTaskDetail/001"
@@ -41,12 +41,13 @@ export interface DashboardTaskUsageReader {
4141
}
4242

4343
/**
44-
* Pages the immutable History task catalog, batch-loads direct task usage for
45-
* every required subtree, then composes one summary per catalog row.
44+
* Pages the immutable History task catalog (root tasks only), batch-loads
45+
* direct task usage for every required subtree, then composes one summary per
46+
* catalog row plus one per direct child (`childTasks`).
4647
*
47-
* When `rangeMs` is bounded, the catalog pages only tasks whose creation
48-
* timestamp falls inside the range and per-task figures aggregate only
49-
* in-range usage events. An absent or unbounded range keeps all-time behavior.
48+
* When `rangeMs` is bounded, the catalog pages only roots whose subtree has a
49+
* task created inside the range, and per-task figures aggregate only in-range
50+
* usage events. An absent or unbounded range keeps all-time behavior.
5051
*/
5152
export function computeTaskPage(
5253
catalog: DashboardTaskCatalog,
@@ -59,10 +60,16 @@ export function computeTaskPage(
5960
const catalogPage = catalog.getPage(cursor, limit, rangeMs)
6061
const usageByTaskId = db.queryTaskUsageByTaskIds(collectPageSubtreeTaskIds(catalog, catalogPage.tasks), rangeMs)
6162

63+
// Direct children of this page's roots ride along so the client can render
64+
// an expanded root without an extra round-trip. Their usage rows are
65+
// already loaded (children are part of their root's subtree).
66+
const childTaskIds = catalogPage.tasks.flatMap((taskId) => catalog.childrenByParentId.get(taskId) ?? [])
67+
6268
return {
6369
requestId,
6470
catalogRevision: catalog.catalogRevision,
6571
tasks: catalogPage.tasks.map((taskId) => computeTaskSummary(catalog, taskId, usageByTaskId)),
72+
childTasks: childTaskIds.map((taskId) => computeTaskSummary(catalog, taskId, usageByTaskId)),
6673
cursor: catalogPage.cursor,
6774
totalEstimate: catalogPage.totalEstimate,
6875
}
@@ -73,8 +80,8 @@ export function computeTaskPage(
7380
* Callers use this for stream upserts after usage mutations without changing
7481
* catalog membership or pagination order.
7582
*
76-
* When `rangeMs` is bounded, tasks whose creation timestamp falls outside the
77-
* range are dropped (matching page membership) and figures aggregate only
83+
* When `rangeMs` is bounded, tasks whose subtree has no task created inside
84+
* the range are dropped (matching page membership) and figures aggregate only
7885
* in-range usage events.
7986
*/
8087
export function computeTaskSummaries(
@@ -83,10 +90,9 @@ export function computeTaskSummaries(
8390
taskIds: readonly string[],
8491
rangeMs?: StatsQueryRangeMs,
8592
): DashboardTaskSummary[] {
86-
const knownTaskIds = [...new Set(taskIds)].filter((taskId) => {
87-
const item = catalog.byId.get(taskId)
88-
return item !== undefined && isWithinStatsQueryRange(rangeMs, item.ts)
89-
})
93+
const knownTaskIds = [...new Set(taskIds)].filter(
94+
(taskId) => catalog.byId.has(taskId) && catalog.isSubtreeWithinRange(rangeMs, taskId),
95+
)
9096
const usageByTaskId = db.queryTaskUsageByTaskIds(collectPageSubtreeTaskIds(catalog, knownTaskIds), rangeMs)
9197
return knownTaskIds.map((taskId) => computeTaskSummary(catalog, taskId, usageByTaskId))
9298
}
@@ -158,6 +164,7 @@ function computeTaskSummary(
158164
model: subtreeUsage.model,
159165
provider: subtreeUsage.provider,
160166
eventCount: subtreeUsage.eventCount,
167+
childTaskIds: [...(catalog.childrenByParentId.get(taskId) ?? [])],
161168
}
162169
}
163170

src/services/stats/UsageStatsStreamCoordinator.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,9 @@ export class UsageStatsStreamCoordinator {
559559
state.subscription.sessionPageSize,
560560
resolveStatsQueryRangeMs(state.subscription.range),
561561
)
562-
state.visibleTaskIds = new Set(tasks.tasks.map((task) => task.taskId))
562+
state.visibleTaskIds = new Set(
563+
[...tasks.tasks, ...(tasks.childTasks ?? [])].map((task) => task.taskId),
564+
)
563565
return {
564566
requestId: state.subscription.requestId,
565567
generation,
@@ -678,7 +680,9 @@ export class UsageStatsStreamCoordinator {
678680
state.subscription.sessionPageSize,
679681
resolveStatsQueryRangeMs(state.subscription.range),
680682
)
681-
state.visibleTaskIds = new Set(tasks.tasks.map((task) => task.taskId))
683+
state.visibleTaskIds = new Set(
684+
[...tasks.tasks, ...(tasks.childTasks ?? [])].map((task) => task.taskId),
685+
)
682686
return {
683687
requestId: state.subscription.requestId,
684688
generation,

src/services/stats/__tests__/DashboardTaskCatalog.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,70 @@ describe("DashboardTaskCatalog", () => {
225225

226226
catalog.dispose()
227227
})
228+
229+
it("pages root tasks only, promoting orphans whose parent is absent", () => {
230+
const source = createCatalogSource([
231+
makeHistoryItem({ id: "root-b", ts: 500 }),
232+
makeHistoryItem({ id: "child-of-b", ts: 450, parentTaskId: "root-b" }),
233+
makeHistoryItem({ id: "root-a", ts: 400 }),
234+
makeHistoryItem({ id: "grandchild-of-b", ts: 350, parentTaskId: "child-of-b" }),
235+
makeHistoryItem({ id: "orphan", ts: 300, parentTaskId: "missing-parent" }),
236+
])
237+
const catalog = new DashboardTaskCatalog(source.source)
238+
239+
expect(catalog.orderedRootTaskIds).toEqual(["root-b", "root-a", "orphan"])
240+
241+
const page = catalog.getPage()
242+
expect(page.tasks).toEqual(["root-b", "root-a", "orphan"])
243+
expect(page.totalEstimate).toBe(3)
244+
245+
catalog.dispose()
246+
})
247+
248+
it("pages roots with cursor continuity when subtasks share the root ordering", () => {
249+
const source = createCatalogSource([
250+
makeHistoryItem({ id: "root-c", ts: 300 }),
251+
makeHistoryItem({ id: "child-c", ts: 250, parentTaskId: "root-c" }),
252+
makeHistoryItem({ id: "root-b", ts: 200 }),
253+
makeHistoryItem({ id: "child-b", ts: 150, parentTaskId: "root-b" }),
254+
makeHistoryItem({ id: "root-a", ts: 100 }),
255+
])
256+
const catalog = new DashboardTaskCatalog(source.source)
257+
const traversed: string[] = []
258+
let cursor: string | undefined
259+
260+
do {
261+
const page = catalog.getPage(cursor, 2)
262+
traversed.push(...page.tasks)
263+
cursor = page.cursor
264+
} while (cursor)
265+
266+
expect(traversed).toEqual(["root-c", "root-b", "root-a"])
267+
268+
catalog.dispose()
269+
})
270+
271+
it("includes a root in a bounded range when any descendant was created inside it", () => {
272+
const source = createCatalogSource([
273+
makeHistoryItem({ id: "old-root", ts: 100 }),
274+
makeHistoryItem({ id: "new-child", ts: 500, parentTaskId: "old-root" }),
275+
makeHistoryItem({ id: "new-grandchild", ts: 600, parentTaskId: "new-child" }),
276+
makeHistoryItem({ id: "out-root", ts: 50 }),
277+
makeHistoryItem({ id: "out-child", ts: 60, parentTaskId: "out-root" }),
278+
makeHistoryItem({ id: "in-root", ts: 400 }),
279+
makeHistoryItem({ id: "out-child-of-in-root", ts: 700, parentTaskId: "in-root" }),
280+
])
281+
const catalog = new DashboardTaskCatalog(source.source)
282+
const rangeMs = { fromMs: 300, toMs: 550 }
283+
284+
const page = catalog.getPage(undefined, 50, rangeMs)
285+
// old-root: descendant new-child (500) in range -> included.
286+
// out-root: root (50) and child (60) both out of range -> excluded.
287+
// in-root: root (400) in range -> included even though its child is not.
288+
// new-grandchild (600) is out of range but is not a root anyway.
289+
expect(page.tasks).toEqual(["in-root", "old-root"])
290+
expect(page.totalEstimate).toBe(2)
291+
292+
catalog.dispose()
293+
})
228294
})

0 commit comments

Comments
 (0)