TaskHeader.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import React, { memo, useMemo, useRef, useState } from "react"
  2. import { useWindowSize } from "react-use"
  3. import prettyBytes from "pretty-bytes"
  4. import { useTranslation } from "react-i18next"
  5. import { vscode } from "@/utils/vscode"
  6. import { formatLargeNumber } from "@/utils/format"
  7. import { calculateTokenDistribution, getMaxTokensForModel } from "@/utils/model-utils"
  8. import { Button } from "@/components/ui"
  9. import { ClineMessage } from "../../../../src/shared/ExtensionMessage"
  10. import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
  11. import { HistoryItem } from "../../../../src/shared/HistoryItem"
  12. import { useExtensionState } from "../../context/ExtensionStateContext"
  13. import Thumbnails from "../common/Thumbnails"
  14. import { normalizeApiConfiguration } from "../settings/ApiOptions"
  15. import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
  16. import { cn } from "@/lib/utils"
  17. import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react"
  18. interface TaskHeaderProps {
  19. task: ClineMessage
  20. tokensIn: number
  21. tokensOut: number
  22. doesModelSupportPromptCache: boolean
  23. cacheWrites?: number
  24. cacheReads?: number
  25. totalCost: number
  26. contextTokens: number
  27. onClose: () => void
  28. }
  29. const TaskHeader: React.FC<TaskHeaderProps> = ({
  30. task,
  31. tokensIn,
  32. tokensOut,
  33. doesModelSupportPromptCache,
  34. cacheWrites,
  35. cacheReads,
  36. totalCost,
  37. contextTokens,
  38. onClose,
  39. }) => {
  40. const { t } = useTranslation()
  41. const { apiConfiguration, currentTaskItem } = useExtensionState()
  42. const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration])
  43. const [isTaskExpanded, setIsTaskExpanded] = useState(false)
  44. const textContainerRef = useRef<HTMLDivElement>(null)
  45. const textRef = useRef<HTMLDivElement>(null)
  46. const contextWindow = selectedModelInfo?.contextWindow || 1
  47. const { width: windowWidth } = useWindowSize()
  48. const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"
  49. return (
  50. <div className="py-2 px-3">
  51. <div
  52. className={cn(
  53. "rounded-xs p-2.5 flex flex-col gap-1.5 relative z-1 border",
  54. !!isTaskExpanded
  55. ? "border-vscode-panel-border text-vscode-foreground"
  56. : "border-vscode-panel-border/80 text-vscode-foreground/80",
  57. )}>
  58. <div className="flex justify-between items-center gap-2">
  59. <div
  60. className="flex items-center cursor-pointer -ml-0.5 select-none grow min-w-0"
  61. onClick={() => setIsTaskExpanded(!isTaskExpanded)}>
  62. <div className="flex items-center shrink-0">
  63. <span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
  64. </div>
  65. <div className="ml-1.5 whitespace-nowrap overflow-hidden text-ellipsis grow min-w-0">
  66. <span className="font-bold">
  67. {t("chat:task.title")}
  68. {!isTaskExpanded && ":"}
  69. </span>
  70. {!isTaskExpanded && <span className="ml-1">{highlightMentions(task.text, false)}</span>}
  71. </div>
  72. </div>
  73. <Button
  74. variant="ghost"
  75. size="icon"
  76. onClick={onClose}
  77. title={t("chat:task.closeAndStart")}
  78. className="shrink-0 w-5 h-5">
  79. <span className="codicon codicon-close" />
  80. </Button>
  81. </div>
  82. {/* Collapsed state: Track context and cost if we have any */}
  83. {!isTaskExpanded && contextWindow > 0 && (
  84. <div className={`w-full flex flex-row gap-1 h-auto`}>
  85. <ContextWindowProgress
  86. contextWindow={contextWindow}
  87. contextTokens={contextTokens || 0}
  88. maxTokens={getMaxTokensForModel(selectedModelInfo, apiConfiguration)}
  89. />
  90. {!!totalCost && <VSCodeBadge>${totalCost.toFixed(2)}</VSCodeBadge>}
  91. </div>
  92. )}
  93. {/* Expanded state: Show task text and images */}
  94. {isTaskExpanded && (
  95. <>
  96. <div
  97. ref={textContainerRef}
  98. className="-mt-0.5 text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
  99. <div
  100. ref={textRef}
  101. className="overflow-auto max-h-80 whitespace-pre-wrap break-words break-anywhere"
  102. style={{
  103. display: "-webkit-box",
  104. WebkitLineClamp: "unset",
  105. WebkitBoxOrient: "vertical",
  106. }}>
  107. {highlightMentions(task.text, false)}
  108. </div>
  109. </div>
  110. {task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
  111. <div className="flex flex-col gap-1">
  112. {isTaskExpanded && contextWindow > 0 && (
  113. <div
  114. className={`w-full flex ${windowWidth < 400 ? "flex-col" : "flex-row"} gap-1 h-auto`}>
  115. <div className="flex items-center gap-1 flex-shrink-0">
  116. <span className="font-bold" data-testid="context-window-label">
  117. {t("chat:task.contextWindow")}
  118. </span>
  119. </div>
  120. <ContextWindowProgress
  121. contextWindow={contextWindow}
  122. contextTokens={contextTokens || 0}
  123. maxTokens={getMaxTokensForModel(selectedModelInfo, apiConfiguration)}
  124. />
  125. </div>
  126. )}
  127. <div className="flex justify-between items-center h-[20px]">
  128. <div className="flex items-center gap-1 flex-wrap">
  129. <span className="font-bold">{t("chat:task.tokens")}</span>
  130. <span className="flex items-center gap-[3px]">
  131. <i className="codicon codicon-arrow-up text-xs font-bold -mb-0.5" />
  132. {formatLargeNumber(tokensIn || 0)}
  133. </span>
  134. <span className="flex items-center gap-[3px]">
  135. <i className="codicon codicon-arrow-down text-xs font-bold -mb-0.5" />
  136. {formatLargeNumber(tokensOut || 0)}
  137. </span>
  138. </div>
  139. {!totalCost && <TaskActions item={currentTaskItem} />}
  140. </div>
  141. {shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
  142. <div className="flex items-center gap-1 flex-wrap h-[20px]">
  143. <span className="font-bold">{t("chat:task.cache")}</span>
  144. <span className="flex items-center gap-1">
  145. <i className="codicon codicon-database text-xs font-bold" />+
  146. {formatLargeNumber(cacheWrites || 0)}
  147. </span>
  148. <span className="flex items-center gap-1">
  149. <i className="codicon codicon-arrow-right text-xs font-bold" />
  150. {formatLargeNumber(cacheReads || 0)}
  151. </span>
  152. </div>
  153. )}
  154. {!!totalCost && (
  155. <div className="flex justify-between items-center h-[20px]">
  156. <div className="flex items-center gap-1">
  157. <span className="font-bold">{t("chat:task.apiCost")}</span>
  158. <span>${totalCost?.toFixed(2)}</span>
  159. </div>
  160. <TaskActions item={currentTaskItem} />
  161. </div>
  162. )}
  163. </div>
  164. </>
  165. )}
  166. </div>
  167. </div>
  168. )
  169. }
  170. export const highlightMentions = (text?: string, withShadow = true) => {
  171. if (!text) return text
  172. const parts = text.split(mentionRegexGlobal)
  173. return parts.map((part, index) => {
  174. if (index % 2 === 0) {
  175. // This is regular text
  176. return part
  177. } else {
  178. // This is a mention
  179. return (
  180. <span
  181. key={index}
  182. className={`${withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"} cursor-pointer`}
  183. onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
  184. @{part}
  185. </span>
  186. )
  187. }
  188. })
  189. }
  190. const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
  191. const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
  192. const { t } = useTranslation()
  193. return (
  194. <div className="flex flex-row gap-1">
  195. <Button
  196. variant="ghost"
  197. size="sm"
  198. title={t("chat:task.export")}
  199. onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
  200. <span className="codicon codicon-desktop-download" />
  201. </Button>
  202. {!!item?.size && item.size > 0 && (
  203. <>
  204. <Button
  205. variant="ghost"
  206. size="sm"
  207. title={t("chat:task.delete")}
  208. onClick={(e) => {
  209. e.stopPropagation()
  210. if (e.shiftKey) {
  211. vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
  212. } else {
  213. setDeleteTaskId(item.id)
  214. }
  215. }}>
  216. <span className="codicon codicon-trash" />
  217. {prettyBytes(item.size)}
  218. </Button>
  219. {deleteTaskId && (
  220. <DeleteTaskDialog
  221. taskId={deleteTaskId}
  222. onOpenChange={(open) => !open && setDeleteTaskId(null)}
  223. open
  224. />
  225. )}
  226. </>
  227. )}
  228. </div>
  229. )
  230. }
  231. interface ContextWindowProgressProps {
  232. contextWindow: number
  233. contextTokens: number
  234. maxTokens?: number
  235. }
  236. const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens }: ContextWindowProgressProps) => {
  237. const { t } = useTranslation()
  238. // Use the shared utility function to calculate all token distribution values
  239. const tokenDistribution = useMemo(
  240. () => calculateTokenDistribution(contextWindow, contextTokens, maxTokens),
  241. [contextWindow, contextTokens, maxTokens],
  242. )
  243. // Destructure the values we need
  244. const { currentPercent, reservedPercent, availableSize, reservedForOutput, availablePercent } = tokenDistribution
  245. // For display purposes
  246. const safeContextWindow = Math.max(0, contextWindow)
  247. const safeContextTokens = Math.max(0, contextTokens)
  248. return (
  249. <>
  250. <div className="flex items-center gap-2 flex-1 whitespace-nowrap px-2">
  251. <div data-testid="context-tokens-count">{formatLargeNumber(safeContextTokens)}</div>
  252. <div className="flex-1 relative">
  253. {/* Invisible overlay for hover area */}
  254. <div
  255. className="absolute w-full h-4 -top-[7px] z-5"
  256. title={t("chat:tokenProgress.availableSpace", { amount: formatLargeNumber(availableSize) })}
  257. data-testid="context-available-space"
  258. />
  259. {/* Main progress bar container */}
  260. <div className="flex items-center h-1 rounded-[2px] overflow-hidden w-full bg-[color-mix(in_srgb,var(--vscode-foreground)_20%,transparent)]">
  261. {/* Current tokens container */}
  262. <div className="relative h-full" style={{ width: `${currentPercent}%` }}>
  263. {/* Invisible overlay for current tokens section */}
  264. <div
  265. className="absolute h-4 -top-[7px] w-full z-6"
  266. title={t("chat:tokenProgress.tokensUsed", {
  267. used: formatLargeNumber(safeContextTokens),
  268. total: formatLargeNumber(safeContextWindow),
  269. })}
  270. data-testid="context-tokens-used"
  271. />
  272. {/* Current tokens used - darkest */}
  273. <div className="h-full w-full bg-[var(--vscode-foreground)] transition-width duration-300 ease-out" />
  274. </div>
  275. {/* Container for reserved tokens */}
  276. <div className="relative h-full" style={{ width: `${reservedPercent}%` }}>
  277. {/* Invisible overlay for reserved section */}
  278. <div
  279. className="absolute h-4 -top-[7px] w-full z-6"
  280. title={t("chat:tokenProgress.reservedForResponse", {
  281. amount: formatLargeNumber(reservedForOutput),
  282. })}
  283. data-testid="context-reserved-tokens"
  284. />
  285. {/* Reserved for output section - medium gray */}
  286. <div className="h-full w-full bg-[color-mix(in_srgb,var(--vscode-foreground)_30%,transparent)] transition-width duration-300 ease-out" />
  287. </div>
  288. {/* Empty section (if any) */}
  289. {availablePercent > 0 && (
  290. <div className="relative h-full" style={{ width: `${availablePercent}%` }}>
  291. {/* Invisible overlay for available space */}
  292. <div
  293. className="absolute h-4 -top-[7px] w-full z-6"
  294. title={t("chat:tokenProgress.availableSpace", {
  295. amount: formatLargeNumber(availableSize),
  296. })}
  297. data-testid="context-available-space-section"
  298. />
  299. </div>
  300. )}
  301. </div>
  302. </div>
  303. <div data-testid="context-window-size">{formatLargeNumber(safeContextWindow)}</div>
  304. </div>
  305. </>
  306. )
  307. }
  308. export default memo(TaskHeader)