TaskHeader.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. import React, { memo, useEffect, useMemo, useRef, useState } from "react"
  2. import { useWindowSize } from "react-use"
  3. import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
  4. import prettyBytes from "pretty-bytes"
  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. interface TaskHeaderProps {
  17. task: ClineMessage
  18. tokensIn: number
  19. tokensOut: number
  20. doesModelSupportPromptCache: boolean
  21. cacheWrites?: number
  22. cacheReads?: number
  23. totalCost: number
  24. contextTokens: number
  25. onClose: () => void
  26. }
  27. const TaskHeader: React.FC<TaskHeaderProps> = ({
  28. task,
  29. tokensIn,
  30. tokensOut,
  31. doesModelSupportPromptCache,
  32. cacheWrites,
  33. cacheReads,
  34. totalCost,
  35. contextTokens,
  36. onClose,
  37. }) => {
  38. const { apiConfiguration, currentTaskItem } = useExtensionState()
  39. const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration])
  40. const [isTaskExpanded, setIsTaskExpanded] = useState(true)
  41. const [isTextExpanded, setIsTextExpanded] = useState(false)
  42. const [showSeeMore, setShowSeeMore] = useState(false)
  43. const textContainerRef = useRef<HTMLDivElement>(null)
  44. const textRef = useRef<HTMLDivElement>(null)
  45. const contextWindow = selectedModelInfo?.contextWindow || 1
  46. /*
  47. When dealing with event listeners in React components that depend on state
  48. variables, we face a challenge. We want our listener to always use the most
  49. up-to-date version of a callback function that relies on current state, but
  50. we don't want to constantly add and remove event listeners as that function
  51. updates. This scenario often arises with resize listeners or other window
  52. events. Simply adding the listener in a useEffect with an empty dependency
  53. array risks using stale state, while including the callback in the
  54. dependencies can lead to unnecessary re-registrations of the listener. There
  55. are react hook libraries that provide a elegant solution to this problem by
  56. utilizing the useRef hook to maintain a reference to the latest callback
  57. function without triggering re-renders or effect re-runs. This approach
  58. ensures that our event listener always has access to the most current state
  59. while minimizing performance overhead and potential memory leaks from
  60. multiple listener registrations.
  61. Sources
  62. - https://usehooks-ts.com/react-hook/use-event-listener
  63. - https://streamich.github.io/react-use/?path=/story/sensors-useevent--docs
  64. - https://github.com/streamich/react-use/blob/master/src/useEvent.ts
  65. - https://stackoverflow.com/questions/55565444/how-to-register-event-with-useeffect-hooks
  66. Before:
  67. const updateMaxHeight = useCallback(() => {
  68. if (isExpanded && textContainerRef.current) {
  69. const maxHeight = window.innerHeight * (3 / 5)
  70. textContainerRef.current.style.maxHeight = `${maxHeight}px`
  71. }
  72. }, [isExpanded])
  73. useEffect(() => {
  74. updateMaxHeight()
  75. }, [isExpanded, updateMaxHeight])
  76. useEffect(() => {
  77. window.removeEventListener("resize", updateMaxHeight)
  78. window.addEventListener("resize", updateMaxHeight)
  79. return () => {
  80. window.removeEventListener("resize", updateMaxHeight)
  81. }
  82. }, [updateMaxHeight])
  83. After:
  84. */
  85. const { height: windowHeight, width: windowWidth } = useWindowSize()
  86. useEffect(() => {
  87. if (isTextExpanded && textContainerRef.current) {
  88. const maxHeight = windowHeight * (1 / 2)
  89. textContainerRef.current.style.maxHeight = `${maxHeight}px`
  90. }
  91. }, [isTextExpanded, windowHeight])
  92. useEffect(() => {
  93. if (textRef.current && textContainerRef.current) {
  94. let textContainerHeight = textContainerRef.current.clientHeight
  95. if (!textContainerHeight) {
  96. textContainerHeight = textContainerRef.current.getBoundingClientRect().height
  97. }
  98. const isOverflowing = textRef.current.scrollHeight > textContainerHeight
  99. // necessary to show see more button again if user resizes window to expand and then back to collapse
  100. if (!isOverflowing) {
  101. setIsTextExpanded(false)
  102. }
  103. setShowSeeMore(isOverflowing)
  104. }
  105. }, [task.text, windowWidth])
  106. const isCostAvailable = useMemo(() => {
  107. return (
  108. apiConfiguration?.apiProvider !== "openai" &&
  109. apiConfiguration?.apiProvider !== "ollama" &&
  110. apiConfiguration?.apiProvider !== "lmstudio" &&
  111. apiConfiguration?.apiProvider !== "gemini"
  112. )
  113. }, [apiConfiguration?.apiProvider])
  114. const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"
  115. return (
  116. <div style={{ padding: "10px 13px 10px 13px" }}>
  117. <div
  118. style={{
  119. backgroundColor: "var(--vscode-badge-background)",
  120. color: "var(--vscode-badge-foreground)",
  121. borderRadius: "3px",
  122. padding: "9px 10px 9px 14px",
  123. display: "flex",
  124. flexDirection: "column",
  125. gap: 6,
  126. position: "relative",
  127. zIndex: 1,
  128. }}>
  129. <div
  130. style={{
  131. display: "flex",
  132. justifyContent: "space-between",
  133. alignItems: "center",
  134. }}>
  135. <div
  136. style={{
  137. display: "flex",
  138. alignItems: "center",
  139. cursor: "pointer",
  140. marginLeft: -2,
  141. userSelect: "none",
  142. WebkitUserSelect: "none",
  143. MozUserSelect: "none",
  144. msUserSelect: "none",
  145. flexGrow: 1,
  146. minWidth: 0, // This allows the div to shrink below its content size
  147. }}
  148. onClick={() => setIsTaskExpanded(!isTaskExpanded)}>
  149. <div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
  150. <span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
  151. </div>
  152. <div
  153. style={{
  154. marginLeft: 6,
  155. whiteSpace: "nowrap",
  156. overflow: "hidden",
  157. textOverflow: "ellipsis",
  158. flexGrow: 1,
  159. minWidth: 0, // This allows the div to shrink below its content size
  160. }}>
  161. <span style={{ fontWeight: "bold" }}>Task{!isTaskExpanded && ":"}</span>
  162. {!isTaskExpanded && (
  163. <span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>
  164. )}
  165. </div>
  166. </div>
  167. {!isTaskExpanded && isCostAvailable && (
  168. <div
  169. style={{
  170. marginLeft: 10,
  171. backgroundColor: "color-mix(in srgb, var(--vscode-badge-foreground) 70%, transparent)",
  172. color: "var(--vscode-badge-background)",
  173. padding: "2px 4px",
  174. borderRadius: "500px",
  175. fontSize: "11px",
  176. fontWeight: 500,
  177. display: "inline-block",
  178. flexShrink: 0,
  179. }}>
  180. ${totalCost?.toFixed(4)}
  181. </div>
  182. )}
  183. <VSCodeButton
  184. appearance="icon"
  185. onClick={onClose}
  186. style={{ marginLeft: 6, flexShrink: 0, color: "var(--vscode-badge-foreground)" }}
  187. title="Close task and start a new one">
  188. <span className="codicon codicon-close"></span>
  189. </VSCodeButton>
  190. </div>
  191. {isTaskExpanded && (
  192. <>
  193. <div
  194. ref={textContainerRef}
  195. style={{
  196. marginTop: -2,
  197. fontSize: "var(--vscode-font-size)",
  198. overflowY: isTextExpanded ? "auto" : "hidden",
  199. wordBreak: "break-word",
  200. overflowWrap: "anywhere",
  201. position: "relative",
  202. }}>
  203. <div
  204. ref={textRef}
  205. style={{
  206. display: "-webkit-box",
  207. WebkitLineClamp: isTextExpanded ? "unset" : 3,
  208. WebkitBoxOrient: "vertical",
  209. overflow: "hidden",
  210. whiteSpace: "pre-wrap",
  211. wordBreak: "break-word",
  212. overflowWrap: "anywhere",
  213. }}>
  214. {highlightMentions(task.text, false)}
  215. </div>
  216. {!isTextExpanded && showSeeMore && (
  217. <div
  218. style={{
  219. position: "absolute",
  220. right: 0,
  221. bottom: 0,
  222. display: "flex",
  223. alignItems: "center",
  224. }}>
  225. <div
  226. style={{
  227. width: 30,
  228. height: "1.2em",
  229. background:
  230. "linear-gradient(to right, transparent, var(--vscode-badge-background))",
  231. }}
  232. />
  233. <div
  234. style={{
  235. cursor: "pointer",
  236. color: "var(--vscode-badge-foreground)",
  237. fontSize: "11px",
  238. paddingRight: 8,
  239. paddingLeft: 4,
  240. backgroundColor: "var(--vscode-badge-background)",
  241. }}
  242. onClick={() => setIsTextExpanded(!isTextExpanded)}>
  243. See more
  244. </div>
  245. </div>
  246. )}
  247. </div>
  248. {isTextExpanded && showSeeMore && (
  249. <div
  250. style={{
  251. cursor: "pointer",
  252. color: "var(--vscode-textLink-foreground)",
  253. marginLeft: "auto",
  254. textAlign: "right",
  255. paddingRight: 2,
  256. }}
  257. onClick={() => setIsTextExpanded(!isTextExpanded)}>
  258. See less
  259. </div>
  260. )}
  261. {task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
  262. <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
  263. <div className="flex justify-between items-center h-[20px]">
  264. <div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
  265. <span style={{ fontWeight: "bold" }}>Tokens:</span>
  266. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  267. <i
  268. className="codicon codicon-arrow-up"
  269. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
  270. />
  271. {formatLargeNumber(tokensIn || 0)}
  272. </span>
  273. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  274. <i
  275. className="codicon codicon-arrow-down"
  276. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
  277. />
  278. {formatLargeNumber(tokensOut || 0)}
  279. </span>
  280. </div>
  281. {!isCostAvailable && <TaskActions item={currentTaskItem} />}
  282. </div>
  283. {isTaskExpanded && contextWindow > 0 && (
  284. <div
  285. className={`w-full flex ${windowWidth < 400 ? "flex-col" : "flex-row"} gap-1 h-auto`}>
  286. <ContextWindowProgress
  287. contextWindow={contextWindow}
  288. contextTokens={contextTokens || 0}
  289. maxTokens={getMaxTokensForModel(selectedModelInfo, apiConfiguration)}
  290. />
  291. </div>
  292. )}
  293. {shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
  294. <div className="flex items-center gap-1 flex-wrap h-[20px]">
  295. <span style={{ fontWeight: "bold" }}>Cache:</span>
  296. <span className="flex items-center gap-1">
  297. <i
  298. className="codicon codicon-database"
  299. style={{ fontSize: "12px", fontWeight: "bold" }}
  300. />
  301. +{formatLargeNumber(cacheWrites || 0)}
  302. </span>
  303. <span className="flex items-center gap-1">
  304. <i
  305. className="codicon codicon-arrow-right"
  306. style={{ fontSize: "12px", fontWeight: "bold" }}
  307. />
  308. {formatLargeNumber(cacheReads || 0)}
  309. </span>
  310. </div>
  311. )}
  312. {isCostAvailable && (
  313. <div className="flex justify-between items-center h-[20px]">
  314. <div className="flex items-center gap-1">
  315. <span className="font-bold">API Cost:</span>
  316. <span>${totalCost?.toFixed(4)}</span>
  317. </div>
  318. <TaskActions item={currentTaskItem} />
  319. </div>
  320. )}
  321. </div>
  322. </>
  323. )}
  324. </div>
  325. </div>
  326. )
  327. }
  328. export const highlightMentions = (text?: string, withShadow = true) => {
  329. if (!text) return text
  330. const parts = text.split(mentionRegexGlobal)
  331. return parts.map((part, index) => {
  332. if (index % 2 === 0) {
  333. // This is regular text
  334. return part
  335. } else {
  336. // This is a mention
  337. return (
  338. <span
  339. key={index}
  340. className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}
  341. style={{ cursor: "pointer" }}
  342. onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
  343. @{part}
  344. </span>
  345. )
  346. }
  347. })
  348. }
  349. const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
  350. const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
  351. return (
  352. <div className="flex flex-row gap-1">
  353. <Button
  354. variant="ghost"
  355. size="sm"
  356. title="Export task history"
  357. onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
  358. <span className="codicon codicon-cloud-download" />
  359. </Button>
  360. {!!item?.size && item.size > 0 && (
  361. <>
  362. <Button
  363. variant="ghost"
  364. size="sm"
  365. title="Delete Task (Shift + Click to skip confirmation)"
  366. onClick={(e) => {
  367. e.stopPropagation()
  368. if (e.shiftKey) {
  369. vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
  370. } else {
  371. setDeleteTaskId(item.id)
  372. }
  373. }}>
  374. <span className="codicon codicon-trash" />
  375. {prettyBytes(item.size)}
  376. </Button>
  377. {deleteTaskId && (
  378. <DeleteTaskDialog
  379. taskId={deleteTaskId}
  380. onOpenChange={(open) => !open && setDeleteTaskId(null)}
  381. open
  382. />
  383. )}
  384. </>
  385. )}
  386. </div>
  387. )
  388. }
  389. interface ContextWindowProgressProps {
  390. contextWindow: number
  391. contextTokens: number
  392. maxTokens?: number
  393. }
  394. const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens }: ContextWindowProgressProps) => {
  395. // Use the shared utility function to calculate all token distribution values
  396. const tokenDistribution = useMemo(
  397. () => calculateTokenDistribution(contextWindow, contextTokens, maxTokens),
  398. [contextWindow, contextTokens, maxTokens],
  399. )
  400. // Destructure the values we need
  401. const { currentPercent, reservedPercent, availableSize, reservedForOutput, availablePercent } = tokenDistribution
  402. // For display purposes
  403. const safeContextWindow = Math.max(0, contextWindow)
  404. const safeContextTokens = Math.max(0, contextTokens)
  405. return (
  406. <>
  407. <div className="flex items-center gap-1 flex-shrink-0">
  408. <span className="font-bold">Context Window:</span>
  409. </div>
  410. <div className="flex items-center gap-2 flex-1 whitespace-nowrap px-2">
  411. <div>{formatLargeNumber(safeContextTokens)}</div>
  412. <div className="flex-1 relative">
  413. {/* Invisible overlay for hover area */}
  414. <div
  415. className="absolute w-full cursor-pointer"
  416. style={{
  417. height: "16px",
  418. top: "-7px",
  419. zIndex: 5,
  420. }}
  421. title={`Available space: ${formatLargeNumber(availableSize)} tokens`}
  422. />
  423. {/* Main progress bar container */}
  424. <div className="flex items-center h-1 rounded-[2px] overflow-hidden w-full bg-[color-mix(in_srgb,var(--vscode-badge-foreground)_20%,transparent)]">
  425. {/* Current tokens container */}
  426. <div className="relative h-full" style={{ width: `${currentPercent}%` }}>
  427. {/* Invisible overlay for current tokens section */}
  428. <div
  429. className="absolute cursor-pointer"
  430. style={{
  431. height: "16px",
  432. top: "-7px",
  433. width: "100%",
  434. zIndex: 6,
  435. }}
  436. title={`Tokens used: ${formatLargeNumber(safeContextTokens)} of ${formatLargeNumber(safeContextWindow)}`}
  437. />
  438. {/* Current tokens used - darkest */}
  439. <div
  440. className="h-full w-full bg-[var(--vscode-badge-foreground)]"
  441. style={{
  442. transition: "width 0.3s ease-out",
  443. }}
  444. />
  445. </div>
  446. {/* Container for reserved tokens */}
  447. <div className="relative h-full" style={{ width: `${reservedPercent}%` }}>
  448. {/* Invisible overlay for reserved section */}
  449. <div
  450. className="absolute cursor-pointer"
  451. style={{
  452. height: "16px",
  453. top: "-7px",
  454. width: "100%",
  455. zIndex: 6,
  456. }}
  457. title={`Reserved for model response: ${formatLargeNumber(reservedForOutput)} tokens`}
  458. />
  459. {/* Reserved for output section - medium gray */}
  460. <div
  461. className="h-full w-full bg-[color-mix(in_srgb,var(--vscode-badge-foreground)_30%,transparent)]"
  462. style={{
  463. transition: "width 0.3s ease-out",
  464. }}
  465. />
  466. </div>
  467. {/* Empty section (if any) */}
  468. {availablePercent > 0 && (
  469. <div className="relative h-full" style={{ width: `${availablePercent}%` }}>
  470. {/* Invisible overlay for available space */}
  471. <div
  472. className="absolute cursor-pointer"
  473. style={{
  474. height: "16px",
  475. top: "-7px",
  476. width: "100%",
  477. zIndex: 6,
  478. }}
  479. title={`Available space: ${formatLargeNumber(availableSize)} tokens`}
  480. />
  481. </div>
  482. )}
  483. </div>
  484. </div>
  485. <div>{formatLargeNumber(safeContextWindow)}</div>
  486. </div>
  487. </>
  488. )
  489. }
  490. export default memo(TaskHeader)