diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bbfeb23de3e..f2db81286f3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -399,6 +399,7 @@ export type { BudgetIncidentResolutionInput, CostEvent, CostSummary, + IssueCostSummary, CostByAgent, CostByProviderModel, CostByBiller, diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts index 8a77a8f2b7f..57e6be62f83 100644 --- a/packages/shared/src/types/cost.ts +++ b/packages/shared/src/types/cost.ts @@ -28,6 +28,16 @@ export interface CostSummary { utilizationPercent: number; } +export interface IssueCostSummary { + issueId: string; + issueCount: number; + includeDescendants: boolean; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + export interface CostByAgent { agentId: string; agentName: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d0265d31d12..b40d7a3634e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -230,7 +230,7 @@ export type { RoutineExecutionIssueOrigin, RoutineListItem, } from "./routine.js"; -export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; +export type { CostEvent, CostSummary, IssueCostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js"; export type { AgentWakeupResponse, diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index c9428ffff52..f9e32f0f1e7 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -3,7 +3,7 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll } from "vitest"; import { randomUUID } from "node:crypto"; -import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db"; +import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db"; import { costService } from "../services/costs.ts"; import { financeService } from "../services/finance.ts"; import { @@ -45,6 +45,10 @@ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), })); +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getByIdentifier: vi.fn(), +})); const mockHeartbeatService = vi.hoisted(() => ({ cancelBudgetScopeWork: vi.fn().mockResolvedValue(undefined), })); @@ -57,6 +61,15 @@ const mockCostService = vi.hoisted(() => ({ byAgentModel: vi.fn().mockResolvedValue([]), byProvider: vi.fn().mockResolvedValue([]), byBiller: vi.fn().mockResolvedValue([]), + issueTreeSummary: vi.fn().mockResolvedValue({ + issueId: "issue-1", + issueCount: 1, + includeDescendants: true, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }), windowSpend: vi.fn().mockResolvedValue([]), byProject: vi.fn().mockResolvedValue([]), })); @@ -87,6 +100,7 @@ function registerModuleMocks() { financeService: () => mockFinanceService, companyService: () => mockCompanyService, agentService: () => mockAgentService, + issueService: () => mockIssueService, heartbeatService: () => mockHeartbeatService, logActivity: mockLogActivity, })); @@ -161,6 +175,16 @@ beforeEach(() => { budgetMonthlyCents: 100, spentMonthlyCents: 0, }); + mockIssueService.getById.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.getByIdentifier.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + identifier: "PAP-1", + }); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); }); @@ -201,6 +225,24 @@ describe("cost routes", () => { }); }); + it("returns issue subtree cost summaries for issue refs", async () => { + const app = await createApp(); + const res = await request(app).get("/api/issues/PAP-1/cost-summary"); + + expect(res.status).toBe(200); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1"); + expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1"); + expect(res.body).toEqual({ + issueId: "issue-1", + issueCount: 1, + includeDescendants: true, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }); + }); + it("returns 400 for invalid finance event list limits", async () => { const { parseCostLimit } = await loadCostParsers(); expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i); @@ -351,6 +393,7 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { afterEach(async () => { await db.delete(financeEvents); await db.delete(costEvents); + await db.delete(issues); await db.delete(projects); await db.delete(agents); await db.delete(companies); @@ -435,6 +478,143 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { expect(byAgentModelRow?.costCents).toBe(4_000_000_000); }); + it("aggregates issue costs across recursive descendants only", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const grandchildIssueId = randomUUID(); + const siblingIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root", + status: "in_progress", + priority: "medium", + issueNumber: 1, + identifier: "TST-1", + }, + { + id: childIssueId, + companyId, + parentId: rootIssueId, + title: "Child", + status: "done", + priority: "medium", + issueNumber: 2, + identifier: "TST-2", + }, + { + id: grandchildIssueId, + companyId, + parentId: childIssueId, + title: "Grandchild", + status: "done", + priority: "medium", + issueNumber: 3, + identifier: "TST-3", + }, + { + id: siblingIssueId, + companyId, + title: "Sibling", + status: "done", + priority: "medium", + issueNumber: 4, + identifier: "TST-4", + }, + ]); + await db.insert(costEvents).values([ + { + companyId, + agentId, + issueId: rootIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 10, + cachedInputTokens: 1, + outputTokens: 2, + costCents: 100, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + issueId: childIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 20, + cachedInputTokens: 2, + outputTokens: 4, + costCents: 200, + occurredAt: new Date("2026-04-10T00:01:00.000Z"), + }, + { + companyId, + agentId, + issueId: grandchildIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 30, + cachedInputTokens: 3, + outputTokens: 6, + costCents: 300, + occurredAt: new Date("2026-04-10T00:02:00.000Z"), + }, + { + companyId, + agentId, + issueId: siblingIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 40, + cachedInputTokens: 4, + outputTokens: 8, + costCents: 400, + occurredAt: new Date("2026-04-10T00:03:00.000Z"), + }, + ]); + + const summary = await costs.issueTreeSummary(companyId, rootIssueId); + + expect(summary).toEqual({ + issueId: rootIssueId, + issueCount: 3, + includeDescendants: true, + costCents: 600, + inputTokens: 60, + cachedInputTokens: 6, + outputTokens: 12, + }); + }); + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { const companyId = randomUUID(); diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 6bef1858cd7..3f3514e69fb 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -14,6 +14,7 @@ import { financeService, companyService, agentService, + issueService, heartbeatService, logActivity, } from "../services/index.js"; @@ -58,6 +59,14 @@ export function costRoutes( const budgets = budgetService(db, budgetHooks); const companies = companyService(db); const agents = agentService(db); + const issues = issueService(db); + + async function resolveIssueByRef(rawId: string) { + if (/^[A-Z]+-\d+$/i.test(rawId)) { + return issues.getByIdentifier(rawId); + } + return issues.getById(rawId); + } router.post("/companies/:companyId/cost-events", validate(createCostEventSchema), async (req, res) => { const companyId = req.params.companyId as string; @@ -126,6 +135,18 @@ export function costRoutes( res.json(summary); }); + router.get("/issues/:id/cost-summary", async (req, res) => { + const rawId = req.params.id as string; + const issue = await resolveIssueByRef(rawId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const summary = await costs.issueTreeSummary(issue.companyId, issue.id); + res.json(summary); + }); + router.get("/companies/:companyId/costs/by-agent", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 88825b498bd..3cdb88a1b5a 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gte, isNotNull, lt, lte, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; @@ -134,6 +134,61 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { }; }, + issueTreeSummary: async (companyId: string, issueId: string) => { + const issueTreeCondition = sql` + ${issues.id} IN ( + WITH RECURSIVE issue_tree(id) AS ( + SELECT ${issues.id} + FROM ${issues} + WHERE ${issues.companyId} = ${companyId} + AND ${issues.id} = ${issueId} + AND ${issues.hiddenAt} IS NULL + UNION + SELECT child.id + FROM ${issues} child + JOIN issue_tree ON child.parent_id = issue_tree.id + WHERE child.company_id = ${companyId} + AND child.hidden_at IS NULL + ) + SELECT id FROM issue_tree + ) + `; + + const [row] = await db + .select({ + issueCount: sql`count(distinct ${issues.id})::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), + }) + .from(issues) + .leftJoin( + costEvents, + and( + eq(costEvents.companyId, companyId), + eq(costEvents.issueId, issues.id), + ), + ) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + issueTreeCondition, + ), + ); + + return { + issueId, + issueCount: Number(row?.issueCount ?? 0), + includeDescendants: true, + costCents: Number(row?.costCents ?? 0), + inputTokens: Number(row?.inputTokens ?? 0), + cachedInputTokens: Number(row?.cachedInputTokens ?? 0), + outputTokens: Number(row?.outputTokens ?? 0), + }; + }, + byAgent: async (companyId: string, range?: CostDateRange) => { const conditions: ReturnType[] = [eq(costEvents.companyId, companyId)]; if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 61abcba691a..788ef6d9f45 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -8,6 +8,7 @@ import type { FeedbackVote, Issue, IssueAttachment, + IssueCostSummary, IssueComment, IssueDocument, IssueLabel, @@ -168,6 +169,7 @@ export const issuesApi = { getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), + getCostSummary: (id: string) => api.get(`/issues/${id}/cost-summary`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters ?? {})) { diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 514c18ef970..90d5bbb37a4 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import type { ComponentProps, ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; -import type { Issue, RunLivenessState } from "@paperclipai/shared"; +import type { ActivityEvent, Issue, RunLivenessState } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue } from "../api/heartbeats"; @@ -62,6 +62,23 @@ function createRun(overrides: Partial = {}): RunForIssue { }; } +function createActivity(overrides: Partial = {}): ActivityEvent { + return { + id: "activity-1", + companyId: "company-1", + actorType: "system", + actorId: "system", + action: "issue.updated", + entityType: "issue", + entityId: "issue-1", + agentId: null, + runId: null, + details: null, + createdAt: new Date("2026-04-18T19:57:00.000Z"), + ...overrides, + }; +} + function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", @@ -139,6 +156,8 @@ function renderLedger(props: Partial { expect(container.textContent).toContain("Last useful action Unavailable"); }); + it("interleaves run rows and activity rows by timestamp", () => { + renderLedger({ + runs: [ + createRun({ + runId: "run-oldest", + startedAt: "2026-04-18T19:55:00.000Z", + createdAt: "2026-04-18T19:55:00.000Z", + }), + createRun({ + runId: "run-newest", + startedAt: "2026-04-18T19:59:00.000Z", + createdAt: "2026-04-18T19:59:00.000Z", + }), + ], + activityEvents: [ + createActivity({ + id: "activity-middle", + action: "activity-middle", + createdAt: new Date("2026-04-18T19:57:00.000Z"), + }), + ], + renderActivityEvent: (event) => ( +
{event.action}
+ ), + }); + + const text = container.textContent ?? ""; + const newestIndex = text.indexOf("run-newe"); + const activityIndex = text.indexOf("activity-middle"); + const oldestIndex = text.indexOf("run-olde"); + + expect(newestIndex).toBeGreaterThanOrEqual(0); + expect(activityIndex).toBeGreaterThan(newestIndex); + expect(oldestIndex).toBeGreaterThan(activityIndex); + }); + it("shows live runs as pending final checks without missing-data language", () => { renderLedger({ runs: [ @@ -334,7 +389,7 @@ describe("IssueRunLedger", () => { it("shows when older runs are clipped from the ledger", () => { renderLedger({ - runs: Array.from({ length: 10 }, (_, index) => + runs: Array.from({ length: 22 }, (_, index) => createRun({ runId: `run-${index.toString().padStart(8, "0")}`, createdAt: `2026-04-18T19:${String(index).padStart(2, "0")}:00.000Z`, @@ -342,7 +397,7 @@ describe("IssueRunLedger", () => { ), }); - expect(container.textContent).toContain("2 older runs not shown"); + expect(container.textContent).toContain("2 older items not shown"); }); it("renders stale-run banner, watchdog actions, and silence badge for live runs", () => { diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index e120b65bfa8..9a28e389c25 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react"; -import type { Issue, Agent } from "@paperclipai/shared"; +import { useMemo, useState, type ReactNode } from "react"; +import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { accessApi, type CurrentBoardAccess } from "../api/access"; @@ -24,6 +24,8 @@ type IssueRunLedgerProps = { childIssues: Issue[]; agentMap: ReadonlyMap; hasLiveRuns: boolean; + activityEvents?: ActivityEvent[]; + renderActivityEvent?: (event: ActivityEvent) => ReactNode; }; type IssueRunLedgerContentProps = { @@ -33,6 +35,8 @@ type IssueRunLedgerContentProps = { issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: ReadonlyMap>; + activityEvents?: ActivityEvent[]; + renderActivityEvent?: (event: ActivityEvent) => ReactNode; pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null; canRecordWatchdogDecisions?: boolean; watchdogDecisionError?: string | null; @@ -45,6 +49,20 @@ type LedgerRun = RunForIssue & { outputSilence?: ActiveRunForIssue["outputSilence"]; }; +type LedgerFeedItem = + | { + kind: "run"; + id: string; + timestamp: string; + run: LedgerRun; + } + | { + kind: "activity"; + id: string; + timestamp: string; + event: ActivityEvent; + }; + type LivenessCopy = { label: string; tone: string; @@ -345,6 +363,8 @@ export function IssueRunLedger({ childIssues, agentMap, hasLiveRuns, + activityEvents, + renderActivityEvent, }: IssueRunLedgerProps) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); @@ -405,6 +425,8 @@ export function IssueRunLedger({ issueStatus={issueStatus} childIssues={childIssues} agentMap={agentMap} + activityEvents={activityEvents} + renderActivityEvent={renderActivityEvent} pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null} canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)} watchdogDecisionError={watchdogDecisionError} @@ -420,6 +442,8 @@ export function IssueRunLedgerContent({ issueStatus, childIssues, agentMap, + activityEvents, + renderActivityEvent, pendingWatchdogDecision, canRecordWatchdogDecisions = true, watchdogDecisionError, @@ -436,6 +460,34 @@ export function IssueRunLedgerContent({ [ledgerRuns], ); const children = childIssueSummary(childIssues); + const feedItems = useMemo(() => { + const items: LedgerFeedItem[] = []; + for (const run of ledgerRuns) { + items.push({ + kind: "run", + id: run.runId, + timestamp: run.startedAt ?? run.createdAt, + run, + }); + } + if (renderActivityEvent) { + for (const event of activityEvents ?? []) { + items.push({ + kind: "activity", + id: event.id, + timestamp: String(event.createdAt), + event, + }); + } + } + return items.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime(); + const bTime = new Date(b.timestamp).getTime(); + if (aTime !== bTime) return bTime - aTime; + if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1; + return b.id.localeCompare(a.id); + }); + }, [activityEvents, ledgerRuns, renderActivityEvent]); return (
@@ -578,28 +630,40 @@ export function IssueRunLedgerContent({ ) : null} - {ledgerRuns.length === 0 ? ( + {feedItems.length === 0 ? (
- Historical runs without liveness metadata will appear here once linked to this issue. + {renderActivityEvent + ? "Runs and activity will appear here once this issue has history." + : "Historical runs without liveness metadata will appear here once linked to this issue."}
) : ( -
- {ledgerRuns.slice(0, 8).map((run) => { +
+ {feedItems.slice(0, 20).map((item) => { + if (item.kind === "activity") { + return
{renderActivityEvent?.(item.event)}
; + } + const run = item.run; const liveness = livenessCopyForRun(run); const stopReason = stopReasonLabel(run); const duration = formatDuration(run.startedAt, run.finishedAt); const exhausted = hasExhaustedContinuation(run); const continuation = continuationLabel(run); const retryState = describeRunRetryState(run); + const agentName = compactAgentName(run, agentMap); return ( -
-
+
+
+ Run {run.runId.slice(0, 8)} + by {agentName} {statusLabel(run.status)} @@ -646,6 +710,7 @@ export function IssueRunLedgerContent({ {RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label} ) : null} + {relativeTime(item.timestamp)}
@@ -696,9 +761,9 @@ export function IssueRunLedgerContent({
); })} - {ledgerRuns.length > 8 ? ( + {feedItems.length > 20 ? (
- {ledgerRuns.length - 8} older runs not shown + {feedItems.length - 20} older items not shown
) : null}
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c61b216a2fd..25d73a1dcde 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -49,6 +49,7 @@ export const queryKeys = { comments: (issueId: string) => ["issues", "comments", issueId] as const, interactions: (issueId: string) => ["issues", "interactions", issueId] as const, feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const, + costSummary: (issueId: string) => ["issues", "cost-summary", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index effd074b254..83ecd5222bf 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -940,6 +940,11 @@ function IssueDetailActivityTab({ issueId, ), }); + const { data: issueTreeCostSummary } = useQuery({ + queryKey: queryKeys.issues.costSummary(issueId), + queryFn: () => issuesApi.getCostSummary(issueId), + placeholderData: keepPreviousDataForSameQueryTail>>(issueId), + }); const initialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); @@ -981,6 +986,16 @@ function IssueDetailActivityTab({ hasTokens, }; }, [linkedRuns]); + const issueTreeCostTokens = + (issueTreeCostSummary?.inputTokens ?? 0) + (issueTreeCostSummary?.outputTokens ?? 0); + const hasIssueTreeCost = + !!issueTreeCostSummary + && (issueTreeCostSummary.costCents > 0 + || issueTreeCostTokens > 0 + || issueTreeCostSummary.cachedInputTokens > 0 + || issueTreeCostSummary.issueCount > 1); + const shouldShowCostSummary = + (linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost; if (initialLoading) { return ; @@ -996,9 +1011,19 @@ function IssueDetailActivityTab({ childIssues={childIssues} agentMap={agentMap} hasLiveRuns={hasLiveRuns} + activityEvents={activity ?? []} + renderActivityEvent={(evt) => ( +
+
+ + {formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })} + {relativeTime(evt.createdAt)} +
+ +
+ )} />
- {linkedApprovals && linkedApprovals.length > 0 && (
{linkedApprovals.map((approval) => ( @@ -1019,46 +1044,56 @@ function IssueDetailActivityTab({ ))}
)} - {linkedRuns && linkedRuns.length > 0 && ( + {shouldShowCostSummary && (
Cost Summary
- {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( + {!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !hasIssueTreeCost ? (
No cost data yet.
) : ( -
- {issueCostSummary.hasCost && ( - - ${issueCostSummary.cost.toFixed(4)} - - )} - {issueCostSummary.hasTokens && ( - - Tokens {formatTokens(issueCostSummary.totalTokens)} - {issueCostSummary.cached > 0 - ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` - : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} - - )} +
+
+ This issue + {issueCostSummary.hasCost ? ( + + ${issueCostSummary.cost.toFixed(4)} + + ) : null} + {issueCostSummary.hasTokens ? ( + + Tokens {formatTokens(issueCostSummary.totalTokens)} + {issueCostSummary.cached > 0 + ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` + : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} + + ) : null} + {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( + No direct cost data. + ) : null} +
+ {hasIssueTreeCost && issueTreeCostSummary ? ( +
+ + Including sub-issues ${(issueTreeCostSummary.costCents / 100).toLocaleString(undefined, { + style: "currency", + currency: "USD", + minimumFractionDigits: 4, + maximumFractionDigits: 4, + })} + + + Tokens {formatTokens(issueTreeCostTokens)} + {issueTreeCostSummary.cachedInputTokens > 0 + ? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})` + : ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`} + + {issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"} +
+ ) : null}
)}
)} - {!activity || activity.length === 0 ? ( -

No activity yet.

- ) : ( -
- {activity.slice(0, 20).map((evt) => ( -
-
- - {formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })} - {relativeTime(evt.createdAt)} -
- -
- ))} -
- )} + ); }