mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
685 lines
20 KiB
TypeScript
685 lines
20 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import type { KeyboardEvent as ReactKeyboardEvent } 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,
|
|
SquarePen,
|
|
Coins,
|
|
HardDriveDownload,
|
|
HardDriveUpload,
|
|
FoldVertical,
|
|
Globe,
|
|
Pencil,
|
|
} 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 } from "@src/components/ui"
|
|
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
|
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
|
|
import { vscode } from "@src/utils/vscode"
|
|
import { DecoratedVSCodeTextField } from "@src/components/common/DecoratedVSCodeTextField"
|
|
|
|
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
|
|
costBreakdown?: string
|
|
contextTokens: number
|
|
buttonsDisabled: boolean
|
|
handleCondenseContext: (taskId: string) => void
|
|
todos?: any[]
|
|
}
|
|
|
|
const TaskHeader = ({
|
|
task,
|
|
tokensIn,
|
|
tokensOut,
|
|
cacheWrites,
|
|
cacheReads,
|
|
totalCost,
|
|
aggregatedCost,
|
|
hasSubtasks,
|
|
costBreakdown,
|
|
contextTokens,
|
|
buttonsDisabled,
|
|
handleCondenseContext,
|
|
todos,
|
|
}: TaskHeaderProps) => {
|
|
const { t } = useTranslation()
|
|
const {
|
|
apiConfiguration,
|
|
currentTaskItem,
|
|
clineMessages,
|
|
isBrowserSessionActive,
|
|
taskTitlesEnabled = false,
|
|
} = 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 [isEditingTitle, setIsEditingTitle] = useState(false)
|
|
const [titleInput, setTitleInput] = useState(currentTaskItem?.title ?? "")
|
|
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
|
const skipBlurSubmitRef = useRef(false)
|
|
const currentTitle = currentTaskItem?.title?.trim() ?? ""
|
|
|
|
useEffect(() => {
|
|
if (!isEditingTitle) {
|
|
setTitleInput(currentTaskItem?.title ?? "")
|
|
}
|
|
}, [currentTaskItem?.title, isEditingTitle])
|
|
|
|
useEffect(() => {
|
|
setIsEditingTitle(false)
|
|
}, [currentTaskItem?.id])
|
|
|
|
useEffect(() => {
|
|
if (!taskTitlesEnabled) {
|
|
setIsEditingTitle(false)
|
|
return
|
|
}
|
|
|
|
if (isEditingTitle) {
|
|
skipBlurSubmitRef.current = false
|
|
requestAnimationFrame(() => {
|
|
titleInputRef.current?.focus()
|
|
titleInputRef.current?.select()
|
|
})
|
|
}
|
|
}, [isEditingTitle, taskTitlesEnabled])
|
|
|
|
const submitTitle = useCallback(() => {
|
|
if (!taskTitlesEnabled) {
|
|
return
|
|
}
|
|
|
|
if (!currentTaskItem) {
|
|
setIsEditingTitle(false)
|
|
return
|
|
}
|
|
|
|
const trimmed = titleInput.trim()
|
|
const existingTrimmed = currentTaskItem.title?.trim() ?? ""
|
|
|
|
setIsEditingTitle(false)
|
|
|
|
if (trimmed === existingTrimmed) {
|
|
setTitleInput(currentTaskItem.title ?? "")
|
|
return
|
|
}
|
|
|
|
vscode.postMessage({
|
|
type: "setTaskTitle",
|
|
text: trimmed,
|
|
ids: [currentTaskItem.id],
|
|
})
|
|
|
|
setTitleInput(trimmed)
|
|
}, [currentTaskItem, taskTitlesEnabled, titleInput])
|
|
|
|
useEffect(() => {
|
|
if (!isEditingTitle) {
|
|
skipBlurSubmitRef.current = false
|
|
}
|
|
}, [isEditingTitle])
|
|
|
|
const handleTitleBlur = useCallback(() => {
|
|
if (!taskTitlesEnabled) {
|
|
return
|
|
}
|
|
if (skipBlurSubmitRef.current) {
|
|
skipBlurSubmitRef.current = false
|
|
return
|
|
}
|
|
submitTitle()
|
|
}, [submitTitle, taskTitlesEnabled])
|
|
|
|
const handleTitleKeyDown = useCallback(
|
|
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
if (!taskTitlesEnabled) {
|
|
return
|
|
}
|
|
|
|
if (event.key === "Enter") {
|
|
event.preventDefault()
|
|
skipBlurSubmitRef.current = true
|
|
submitTitle()
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault()
|
|
skipBlurSubmitRef.current = true
|
|
setIsEditingTitle(false)
|
|
setTitleInput(currentTaskItem?.title ?? "")
|
|
}
|
|
},
|
|
[currentTaskItem?.title, submitTitle, taskTitlesEnabled],
|
|
)
|
|
|
|
const textContainerRef = useRef<HTMLDivElement>(null)
|
|
const textRef = useRef<HTMLDivElement>(null)
|
|
const contextWindow = model?.contextWindow || 1
|
|
|
|
// Detect if this task had any browser session activity so we can show a grey globe when inactive
|
|
const browserSessionStartIndex = useMemo(() => {
|
|
const msgs = clineMessages || []
|
|
for (let i = 0; i < msgs.length; i++) {
|
|
const m = msgs[i] as any
|
|
if (m?.ask === "browser_action_launch") return i
|
|
}
|
|
return -1
|
|
}, [clineMessages])
|
|
|
|
const showBrowserGlobe = browserSessionStartIndex !== -1 || !!isBrowserSessionActive
|
|
|
|
const condenseButton = (
|
|
<LucideIconButton
|
|
title={t("chat:task.condenseContext")}
|
|
icon={FoldVertical}
|
|
disabled={buttonsDisabled}
|
|
onClick={() => currentTaskItem && handleCondenseContext(currentTaskItem.id)}
|
|
/>
|
|
)
|
|
|
|
const renderTitleEditor = () => (
|
|
<div onClick={(event) => event.stopPropagation()} className="w-full" data-testid="task-title-editor">
|
|
<DecoratedVSCodeTextField
|
|
ref={titleInputRef}
|
|
value={titleInput}
|
|
onInput={(event: any) => setTitleInput(event.target.value)}
|
|
onBlur={handleTitleBlur}
|
|
onKeyDown={handleTitleKeyDown}
|
|
placeholder={t("chat:task.titlePlaceholder")}
|
|
data-testid="task-title-input"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const renderTitleAction = () => {
|
|
if (!taskTitlesEnabled || !currentTaskItem || isEditingTitle) {
|
|
return null
|
|
}
|
|
|
|
const tooltipKey = currentTitle.length > 0 ? "chat:task.editTitle" : "chat:task.addTitle"
|
|
|
|
return (
|
|
<StandardTooltip content={t(tooltipKey)}>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 min-h-[20px] min-w-[20px] p-[2px] cursor-pointer opacity-85 hover:opacity-100 bg-transparent border-none rounded-md text-inherit"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
skipBlurSubmitRef.current = false
|
|
setTitleInput(currentTitle)
|
|
setIsEditingTitle(true)
|
|
}}
|
|
aria-label={t(tooltipKey)}
|
|
data-testid="task-title-edit-button">
|
|
<Pencil size={16} className="opacity-0 group-hover:opacity-100" />
|
|
</button>
|
|
</StandardTooltip>
|
|
)
|
|
}
|
|
|
|
const renderCollapsedTitleContent = () => {
|
|
if (!taskTitlesEnabled || !currentTaskItem) {
|
|
return (
|
|
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
|
|
<Mention text={task.text} />
|
|
</span>
|
|
)
|
|
}
|
|
|
|
if (isEditingTitle) {
|
|
return renderTitleEditor()
|
|
}
|
|
|
|
if (currentTitle.length > 0) {
|
|
return (
|
|
<span className="block truncate text-base" data-testid="task-title-text">
|
|
{currentTitle}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
|
|
<Mention text={task.text} />
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const renderExpandedTitleContent = () => {
|
|
if (!taskTitlesEnabled || !currentTaskItem) {
|
|
return null
|
|
}
|
|
|
|
if (isEditingTitle) {
|
|
return renderTitleEditor()
|
|
}
|
|
|
|
if (currentTitle.length > 0) {
|
|
return (
|
|
<span className="text-base" data-testid="task-title-text">
|
|
{currentTitle}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const hasTodos = todos && Array.isArray(todos) && todos.length > 0
|
|
const expandedTitleContent = renderExpandedTitleContent()
|
|
|
|
return (
|
|
<div className="group pt-2 pb-0 px-3">
|
|
{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 ? (
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-bold">{t("chat:task.title")}</span>
|
|
{renderTitleAction()}
|
|
</div>
|
|
{expandedTitleContent ? (
|
|
<div className="min-w-0">{expandedTitleContent}</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<SquarePen className="size-3 shrink-0" />
|
|
<div className="min-w-0 flex-1">{renderCollapsedTitleContent()}</div>
|
|
{renderTitleAction()}
|
|
</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">
|
|
<Coins className="size-3 shrink-0" />
|
|
<StandardTooltip
|
|
content={
|
|
<div className="space-y-1">
|
|
<div>
|
|
{t("chat:tokenProgress.tokensUsed", {
|
|
used: formatLargeNumber(contextTokens || 0),
|
|
total: formatLargeNumber(contextWindow),
|
|
})}
|
|
</div>
|
|
{(() => {
|
|
const maxTokens = model
|
|
? getModelMaxOutputTokens({
|
|
modelId,
|
|
model,
|
|
settings: apiConfiguration,
|
|
})
|
|
: 0
|
|
const reservedForOutput = maxTokens || 0
|
|
const availableSpace =
|
|
contextWindow - (contextTokens || 0) - reservedForOutput
|
|
|
|
return (
|
|
<>
|
|
{reservedForOutput > 0 && (
|
|
<div>
|
|
{t("chat:tokenProgress.reservedForResponse", {
|
|
amount: formatLargeNumber(reservedForOutput),
|
|
})}
|
|
</div>
|
|
)}
|
|
{availableSpace > 0 && (
|
|
<div>
|
|
{t("chat:tokenProgress.availableSpace", {
|
|
amount: formatLargeNumber(availableSpace),
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
}
|
|
side="top"
|
|
sideOffset={8}>
|
|
<span className="mr-1">
|
|
{formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)}
|
|
</span>
|
|
</StandardTooltip>
|
|
{!!totalCost && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
{showBrowserGlobe && (
|
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<StandardTooltip content={t("chat:browser.session")}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={t("chat:browser.session")}
|
|
onClick={() => vscode.postMessage({ type: "openBrowserSessionPanel" } as any)}
|
|
className={cn(
|
|
"relative h-5 w-5 p-0",
|
|
"text-vscode-foreground opacity-85",
|
|
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]",
|
|
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
|
|
)}>
|
|
<Globe
|
|
className="w-4 h-4"
|
|
style={{
|
|
color: isBrowserSessionActive
|
|
? "#4ade80"
|
|
: "var(--vscode-descriptionForeground)",
|
|
}}
|
|
/>
|
|
</Button>
|
|
</StandardTooltip>
|
|
{isBrowserSessionActive && (
|
|
<span
|
|
className="text-sm font-medium"
|
|
style={{ color: "var(--vscode-testing-iconPassed)" }}>
|
|
{t("chat:browser.active")}
|
|
</span>
|
|
)}
|
|
</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={
|
|
model
|
|
? getModelMaxOutputTokens({
|
|
modelId,
|
|
model,
|
|
settings: apiConfiguration,
|
|
})
|
|
: 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>
|
|
)}
|
|
|
|
{/* 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)
|