mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-25 01:01:18 +00:00
* Taskheader * Subtask messages * View subtask * subtasks in history items * i18n * Table * Lighter visuals * bug * fix: Align tests with implementation behavior * refactor: extract CircularProgress component from TaskHeader - Created reusable CircularProgress component for displaying percentage as a ring - Moved inline SVG calculation from TaskHeader.tsx to dedicated component - Added comprehensive tests for CircularProgress component (14 tests) - Component supports customizable size, strokeWidth, and className - Includes proper accessibility attributes (progressbar role, aria-valuenow) * chore: update StandardTooltip default delay to 600ms As mentioned in the PR description, increased the tooltip delay to 600ms for less intrusive tooltips. The delay is still configurable via the delay prop for components that need a different value. --------- Co-authored-by: Roo Code <roomote@roocode.com>
523 lines
17 KiB
TypeScript
523 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,
|
|
Globe,
|
|
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
|
|
costBreakdown?: string
|
|
contextTokens: number
|
|
buttonsDisabled: boolean
|
|
handleCondenseContext: (taskId: string) => void
|
|
todos?: any[]
|
|
}
|
|
|
|
const TaskHeader = ({
|
|
task,
|
|
tokensIn,
|
|
tokensOut,
|
|
cacheWrites,
|
|
cacheReads,
|
|
totalCost,
|
|
aggregatedCost,
|
|
hasSubtasks,
|
|
parentTaskId,
|
|
costBreakdown,
|
|
contextTokens,
|
|
buttonsDisabled,
|
|
handleCondenseContext,
|
|
todos,
|
|
}: TaskHeaderProps) => {
|
|
const { t } = useTranslation()
|
|
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = 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
|
|
|
|
// 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 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 maxTokens = model
|
|
? getModelMaxOutputTokens({
|
|
modelId,
|
|
model,
|
|
settings: apiConfiguration,
|
|
})
|
|
: 0
|
|
const reservedForOutput = maxTokens || 0
|
|
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">
|
|
{(() => {
|
|
const percentage = Math.round(((contextTokens || 0) / contextWindow) * 100)
|
|
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>
|
|
</>
|
|
)}
|
|
</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)
|