Roo-Code/webview-ui/src/components/chat/TaskHeader.tsx
Roo Code 472c3960b0 feat: show root orchestration cost tally for subtasks
When viewing a subtask spawned by an orchestrator, the TaskHeader now
displays the full orchestration cost (aggregated across all tasks in the
orchestration) alongside the current task cost. This gives users
visibility into total spend without having to navigate back to the
parent task.

Addresses #11928
2026-03-16 14:43:01 +00:00

517 lines
17 KiB
TypeScript

import { memo, useEffect, useRef, useState, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
import { ChevronUp, ChevronDown, HardDriveDownload, HardDriveUpload, FoldVertical, ArrowLeft } from "lucide-react"
import prettyBytes from "pretty-bytes"
import type { ClineMessage } from "@roo-code/types"
import { getModelMaxOutputTokens } from "@roo/api"
import { findLastIndex } from "@roo/array"
import { formatLargeNumber } from "@src/utils/format"
import { cn } from "@src/lib/utils"
import { StandardTooltip, Button, Table, TableBody, TableRow, TableCell, CircularProgress } from "@src/components/ui"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { vscode } from "@src/utils/vscode"
import Thumbnails from "../common/Thumbnails"
import { TaskActions } from "./TaskActions"
import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
import { LucideIconButton } from "./LucideIconButton"
export interface TaskHeaderProps {
task: ClineMessage
tokensIn: number
tokensOut: number
cacheWrites?: number
cacheReads?: number
totalCost: number
aggregatedCost?: number
hasSubtasks?: boolean
parentTaskId?: string
rootOrchestrationCost?: number
costBreakdown?: string
contextTokens: number
buttonsDisabled: boolean
handleCondenseContext: (taskId: string) => void
todos?: any[]
}
const TaskHeader = ({
task,
tokensIn,
tokensOut,
cacheWrites,
cacheReads,
totalCost,
aggregatedCost,
hasSubtasks,
parentTaskId,
rootOrchestrationCost,
costBreakdown,
contextTokens,
buttonsDisabled,
handleCondenseContext,
todos,
}: TaskHeaderProps) => {
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem, clineMessages } = useExtensionState()
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({
autoOpenOnAuth: false,
})
// Check if the task is complete by looking at the last relevant message (skipping resume messages)
const isTaskComplete =
clineMessages && clineMessages.length > 0
? (() => {
const lastRelevantIndex = findLastIndex(
clineMessages,
(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
)
return lastRelevantIndex !== -1
? clineMessages[lastRelevantIndex]?.ask === "completion_result"
: false
})()
: false
useEffect(() => {
const timer = setTimeout(() => {
if (currentTaskItem && !isTaskComplete) {
setShowLongRunningTaskMessage(true)
}
}, 120_000) // Show upsell after 2 minutes
return () => clearTimeout(timer)
}, [currentTaskItem, isTaskComplete])
const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const contextWindow = model?.contextWindow || 1
// Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip
const maxTokens = useMemo(
() =>
model
? getModelMaxOutputTokens({
modelId,
model,
settings: apiConfiguration,
})
: 0,
[model, modelId, apiConfiguration],
)
const reservedForOutput = maxTokens || 0
const condenseButton = (
<LucideIconButton
title={t("chat:task.condenseContext")}
icon={FoldVertical}
disabled={buttonsDisabled}
onClick={() => currentTaskItem && handleCondenseContext(currentTaskItem.id)}
/>
)
const hasTodos = todos && Array.isArray(todos) && todos.length > 0
// Determine if this is a subtask (has a parent)
const isSubtask = !!parentTaskId
const handleBackToParent = () => {
if (parentTaskId) {
vscode.postMessage({ type: "showTaskWithId", text: parentTaskId })
}
}
return (
<div className="group pt-2 pb-0 px-3">
{isSubtask && (
<div className="mb-2" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
onClick={handleBackToParent}
className="flex items-center gap-1.5 text-xs text-vscode-descriptionForeground hover:text-vscode-foreground">
<ArrowLeft className="size-3" />
{t("chat:task.backToParentTask")}
</Button>
</div>
)}
{showLongRunningTaskMessage && !isTaskComplete && (
<DismissibleUpsell
upsellId="longRunningTask"
onClick={() => openUpsell()}
dismissOnClick={false}
variant="banner">
{t("cloud:upsell.longRunningTask")}
</DismissibleUpsell>
)}
<div
className={cn(
"px-3 pt-2.5 pb-2 flex flex-col gap-1.5 relative z-1 cursor-pointer",
"bg-vscode-input-background hover:bg-vscode-input-background/90",
"text-vscode-foreground/80 hover:text-vscode-foreground",
"shadow-lg shadow-vscode-sideBar-background/50 rounded-xl",
hasTodos && "border-b-0",
)}
onClick={(e) => {
// Don't expand if clicking on todos section
if (e.target instanceof Element && e.target.closest("[data-todo-list]")) {
return
}
// Don't expand if clicking on buttons or interactive elements
if (
e.target instanceof Element &&
(e.target.closest("button") ||
e.target.closest('[role="button"]') ||
e.target.closest(".share-button") ||
e.target.closest("[data-radix-popper-content-wrapper]") ||
e.target.closest("img") ||
e.target.tagName === "IMG")
) {
return
}
// Don't expand/collapse if user is selecting text
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
return
}
setIsTaskExpanded(!isTaskExpanded)
}}>
<div className="flex justify-between items-center gap-0">
<div className="flex items-center select-none grow min-w-0">
<div className="grow min-w-0">
{isTaskExpanded && <span className="font-bold">{t("chat:task.title")}</span>}
{!isTaskExpanded && (
<div className="flex items-center gap-2 whitespace-nowrap overflow-hidden text-ellipsis">
<Mention text={task.text} />
</div>
)}
</div>
<div className="flex items-center shrink-0 ml-2" onClick={(e) => e.stopPropagation()}>
<StandardTooltip content={isTaskExpanded ? t("chat:task.collapse") : t("chat:task.expand")}>
<button
onClick={() => setIsTaskExpanded(!isTaskExpanded)}
className="shrink-0 min-h-[20px] min-w-[20px] p-[2px] cursor-pointer opacity-85 hover:opacity-100 bg-transparent border-none rounded-md">
{isTaskExpanded ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} className="opacity-0 group-hover:opacity-100" />
)}
</button>
</StandardTooltip>
</div>
</div>
</div>
{!isTaskExpanded && contextWindow > 0 && (
<div
className="flex items-center justify-between text-sm text-muted-foreground/70"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<StandardTooltip
content={(() => {
const availableSpace = contextWindow - (contextTokens || 0) - reservedForOutput
return (
<Table className="text-base ml-1.5">
<TableBody>
<TableRow>
<TableCell className="font-medium whitespace-nowrap">
{t("chat:tokenProgress.tokensUsedLabel")}
</TableCell>
<TableCell className="text-right text-[0.9em] font-mono">
{formatLargeNumber(contextTokens || 0)} /{" "}
{formatLargeNumber(contextWindow)}
</TableCell>
</TableRow>
{reservedForOutput > 0 && (
<TableRow>
<TableCell className="font-medium whitespace-nowrap">
{t("chat:tokenProgress.reservedForResponseLabel")}
</TableCell>
<TableCell className="text-right text-[0.9em] font-mono">
{formatLargeNumber(reservedForOutput)}
</TableCell>
</TableRow>
)}
{availableSpace > 0 && (
<TableRow>
<TableCell className="font-medium whitespace-nowrap">
{t("chat:tokenProgress.availableSpaceLabel")}
</TableCell>
<TableCell className="text-right text-[0.9em] font-mono">
{formatLargeNumber(availableSpace)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
})()}
side="top"
sideOffset={8}>
<span className="flex items-center gap-1.5">
{(() => {
// Calculate percentage of available input space used
// Available input space = context window - reserved for output
const availableInputSpace = contextWindow - reservedForOutput
const percentage =
availableInputSpace > 0
? Math.round(((contextTokens || 0) / availableInputSpace) * 100)
: 0
return (
<>
<CircularProgress percentage={percentage} />
<span>{percentage}%</span>
</>
)
})()}
</span>
</StandardTooltip>
{!!totalCost && (
<>
<span>·</span>
<StandardTooltip
content={
hasSubtasks ? (
<div>
<div>
{t("chat:costs.totalWithSubtasks", {
cost: (aggregatedCost ?? totalCost).toFixed(2),
})}
</div>
{costBreakdown && (
<div className="text-xs mt-1">{costBreakdown}</div>
)}
</div>
) : (
<div>{t("chat:costs.total", { cost: totalCost.toFixed(2) })}</div>
)
}
side="top"
sideOffset={8}>
<>
<span>
${(aggregatedCost ?? totalCost).toFixed(2)}
{hasSubtasks && (
<span
className="text-xs ml-1"
title={t("chat:costs.includesSubtasks")}>
*
</span>
)}
</span>
</>
</StandardTooltip>
</>
)}
{typeof rootOrchestrationCost === "number" && rootOrchestrationCost > 0 && (
<>
<span>·</span>
<StandardTooltip
content={
<div>
{t("chat:costs.orchestrationTotal", {
cost: rootOrchestrationCost.toFixed(2),
})}
</div>
}
side="top"
sideOffset={8}>
<span className="text-vscode-descriptionForeground">
{t("chat:costs.orchestrationLabel")}: ${rootOrchestrationCost.toFixed(2)}
</span>
</StandardTooltip>
</>
)}
</div>
</div>
)}
{/* Expanded state: Show task text and images */}
{isTaskExpanded && (
<>
<div
ref={textContainerRef}
className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
<div
ref={textRef}
className="overflow-auto max-h-80 whitespace-pre-wrap break-words break-anywhere cursor-text py-0.5"
style={{
display: "-webkit-box",
WebkitLineClamp: "unset",
WebkitBoxOrient: "vertical",
}}>
<Mention text={task.text} />
</div>
</div>
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
<div onClick={(e) => e.stopPropagation()}>
<TaskActions item={currentTaskItem} buttonsDisabled={buttonsDisabled} />
</div>
<div className="pt-3 mt-2 -mx-2.5 px-2.5 border-t border-vscode-sideBar-background">
<table className="w-full text-sm">
<tbody>
{contextWindow > 0 && (
<tr>
<th
className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]"
data-testid="context-window-label">
{t("chat:task.contextWindow")}
</th>
<td className="font-light align-top">
<div className={`max-w-md -mt-1.5 flex flex-nowrap gap-1`}>
<ContextWindowProgress
contextWindow={contextWindow}
contextTokens={contextTokens || 0}
maxTokens={maxTokens || undefined}
/>
{condenseButton}
</div>
</td>
</tr>
)}
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]">
{t("chat:task.tokens")}
</th>
<td className="font-light align-top">
<div className="flex items-center gap-1 flex-wrap">
{typeof tokensIn === "number" && tokensIn > 0 && (
<span>↑ {formatLargeNumber(tokensIn)}</span>
)}
{typeof tokensOut === "number" && tokensOut > 0 && (
<span>↓ {formatLargeNumber(tokensOut)}</span>
)}
</div>
</td>
</tr>
{((typeof cacheReads === "number" && cacheReads > 0) ||
(typeof cacheWrites === "number" && cacheWrites > 0)) && (
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]">
{t("chat:task.cache")}
</th>
<td className="font-light align-top">
<div className="flex items-center gap-1 flex-wrap">
{typeof cacheWrites === "number" && cacheWrites > 0 && (
<>
<HardDriveDownload className="size-2.5" />
<span>{formatLargeNumber(cacheWrites)}</span>
</>
)}
{typeof cacheReads === "number" && cacheReads > 0 && (
<>
<HardDriveUpload className="size-2.5" />
<span>{formatLargeNumber(cacheReads)}</span>
</>
)}
</div>
</td>
</tr>
)}
{!!totalCost && (
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]">
{t("chat:task.apiCost")}
</th>
<td className="font-light align-top">
<StandardTooltip
content={
hasSubtasks ? (
<div>
<div>
{t("chat:costs.totalWithSubtasks", {
cost: (aggregatedCost ?? totalCost).toFixed(2),
})}
</div>
{costBreakdown && (
<div className="text-xs mt-1">{costBreakdown}</div>
)}
</div>
) : (
<div>
{t("chat:costs.total", { cost: totalCost.toFixed(2) })}
</div>
)
}
side="top"
sideOffset={8}>
<span>
${(aggregatedCost ?? totalCost).toFixed(2)}
{hasSubtasks && (
<span
className="text-xs ml-1"
title={t("chat:costs.includesSubtasks")}>
*
</span>
)}
</span>
</StandardTooltip>
</td>
</tr>
)}
{typeof rootOrchestrationCost === "number" && rootOrchestrationCost > 0 && (
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]">
{t("chat:costs.orchestrationLabel")}
</th>
<td className="font-light align-top">
<StandardTooltip
content={
<div>
{t("chat:costs.orchestrationTotal", {
cost: rootOrchestrationCost.toFixed(2),
})}
</div>
}
side="top"
sideOffset={8}>
<span>${rootOrchestrationCost.toFixed(2)}</span>
</StandardTooltip>
</td>
</tr>
)}
{/* Size display */}
{!!currentTaskItem?.size && currentTaskItem.size > 0 && (
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-2 h-[20px]">
{t("chat:task.size")}
</th>
<td className="font-light align-top">
{prettyBytes(currentTaskItem.size)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
{/* Todo list - always shown at bottom when todos exist */}
{hasTodos && <TodoListDisplay todos={todos ?? (task as any)?.tool?.todos ?? []} />}
</div>
<CloudUpsellDialog open={isOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />
</div>
)
}
export default memo(TaskHeader)