Roo-Code/webview-ui/src/components/chat/TaskHeader.tsx
Bruno Bergher 5848db66be
ux: Improve subtask visibility and navigation in history and chat views (#10864)
* 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>
2026-01-24 07:21:25 -05:00

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)