TaskHeader.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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 { ClineMessage } from "../../../../src/shared/ExtensionMessage"
  6. import { useExtensionState } from "../../context/ExtensionStateContext"
  7. import { vscode } from "../../utils/vscode"
  8. import Thumbnails from "../common/Thumbnails"
  9. import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
  10. import { formatLargeNumber } from "../../utils/format"
  11. import { normalizeApiConfiguration } from "../settings/ApiOptions"
  12. import { Button } from "../ui"
  13. import { HistoryItem } from "../../../../src/shared/HistoryItem"
  14. interface TaskHeaderProps {
  15. task: ClineMessage
  16. tokensIn: number
  17. tokensOut: number
  18. doesModelSupportPromptCache: boolean
  19. cacheWrites?: number
  20. cacheReads?: number
  21. totalCost: number
  22. contextTokens: number
  23. onClose: () => void
  24. }
  25. const TaskHeader: React.FC<TaskHeaderProps> = ({
  26. task,
  27. tokensIn,
  28. tokensOut,
  29. doesModelSupportPromptCache,
  30. cacheWrites,
  31. cacheReads,
  32. totalCost,
  33. contextTokens,
  34. onClose,
  35. }) => {
  36. const { apiConfiguration, currentTaskItem } = useExtensionState()
  37. const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration])
  38. const [isTaskExpanded, setIsTaskExpanded] = useState(true)
  39. const [isTextExpanded, setIsTextExpanded] = useState(false)
  40. const [showSeeMore, setShowSeeMore] = useState(false)
  41. const textContainerRef = useRef<HTMLDivElement>(null)
  42. const textRef = useRef<HTMLDivElement>(null)
  43. const contextWindow = selectedModelInfo?.contextWindow || 1
  44. /*
  45. When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
  46. Sources
  47. - https://usehooks-ts.com/react-hook/use-event-listener
  48. - https://streamich.github.io/react-use/?path=/story/sensors-useevent--docs
  49. - https://github.com/streamich/react-use/blob/master/src/useEvent.ts
  50. - https://stackoverflow.com/questions/55565444/how-to-register-event-with-useeffect-hooks
  51. Before:
  52. const updateMaxHeight = useCallback(() => {
  53. if (isExpanded && textContainerRef.current) {
  54. const maxHeight = window.innerHeight * (3 / 5)
  55. textContainerRef.current.style.maxHeight = `${maxHeight}px`
  56. }
  57. }, [isExpanded])
  58. useEffect(() => {
  59. updateMaxHeight()
  60. }, [isExpanded, updateMaxHeight])
  61. useEffect(() => {
  62. window.removeEventListener("resize", updateMaxHeight)
  63. window.addEventListener("resize", updateMaxHeight)
  64. return () => {
  65. window.removeEventListener("resize", updateMaxHeight)
  66. }
  67. }, [updateMaxHeight])
  68. After:
  69. */
  70. const { height: windowHeight, width: windowWidth } = useWindowSize()
  71. useEffect(() => {
  72. if (isTextExpanded && textContainerRef.current) {
  73. const maxHeight = windowHeight * (1 / 2)
  74. textContainerRef.current.style.maxHeight = `${maxHeight}px`
  75. }
  76. }, [isTextExpanded, windowHeight])
  77. useEffect(() => {
  78. if (textRef.current && textContainerRef.current) {
  79. let textContainerHeight = textContainerRef.current.clientHeight
  80. if (!textContainerHeight) {
  81. textContainerHeight = textContainerRef.current.getBoundingClientRect().height
  82. }
  83. const isOverflowing = textRef.current.scrollHeight > textContainerHeight
  84. // necessary to show see more button again if user resizes window to expand and then back to collapse
  85. if (!isOverflowing) {
  86. setIsTextExpanded(false)
  87. }
  88. setShowSeeMore(isOverflowing)
  89. }
  90. }, [task.text, windowWidth])
  91. const isCostAvailable = useMemo(() => {
  92. return (
  93. apiConfiguration?.apiProvider !== "openai" &&
  94. apiConfiguration?.apiProvider !== "ollama" &&
  95. apiConfiguration?.apiProvider !== "lmstudio" &&
  96. apiConfiguration?.apiProvider !== "gemini"
  97. )
  98. }, [apiConfiguration?.apiProvider])
  99. const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"
  100. return (
  101. <div style={{ padding: "10px 13px 10px 13px" }}>
  102. <div
  103. style={{
  104. backgroundColor: "var(--vscode-badge-background)",
  105. color: "var(--vscode-badge-foreground)",
  106. borderRadius: "3px",
  107. padding: "9px 10px 9px 14px",
  108. display: "flex",
  109. flexDirection: "column",
  110. gap: 6,
  111. position: "relative",
  112. zIndex: 1,
  113. }}>
  114. <div
  115. style={{
  116. display: "flex",
  117. justifyContent: "space-between",
  118. alignItems: "center",
  119. }}>
  120. <div
  121. style={{
  122. display: "flex",
  123. alignItems: "center",
  124. cursor: "pointer",
  125. marginLeft: -2,
  126. userSelect: "none",
  127. WebkitUserSelect: "none",
  128. MozUserSelect: "none",
  129. msUserSelect: "none",
  130. flexGrow: 1,
  131. minWidth: 0, // This allows the div to shrink below its content size
  132. }}
  133. onClick={() => setIsTaskExpanded(!isTaskExpanded)}>
  134. <div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
  135. <span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
  136. </div>
  137. <div
  138. style={{
  139. marginLeft: 6,
  140. whiteSpace: "nowrap",
  141. overflow: "hidden",
  142. textOverflow: "ellipsis",
  143. flexGrow: 1,
  144. minWidth: 0, // This allows the div to shrink below its content size
  145. }}>
  146. <span style={{ fontWeight: "bold" }}>Task{!isTaskExpanded && ":"}</span>
  147. {!isTaskExpanded && (
  148. <span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>
  149. )}
  150. </div>
  151. </div>
  152. {!isTaskExpanded && isCostAvailable && (
  153. <div
  154. style={{
  155. marginLeft: 10,
  156. backgroundColor: "color-mix(in srgb, var(--vscode-badge-foreground) 70%, transparent)",
  157. color: "var(--vscode-badge-background)",
  158. padding: "2px 4px",
  159. borderRadius: "500px",
  160. fontSize: "11px",
  161. fontWeight: 500,
  162. display: "inline-block",
  163. flexShrink: 0,
  164. }}>
  165. ${totalCost?.toFixed(4)}
  166. </div>
  167. )}
  168. <VSCodeButton appearance="icon" onClick={onClose} style={{ marginLeft: 6, flexShrink: 0 }}>
  169. <span className="codicon codicon-close"></span>
  170. </VSCodeButton>
  171. </div>
  172. {isTaskExpanded && (
  173. <>
  174. <div
  175. ref={textContainerRef}
  176. style={{
  177. marginTop: -2,
  178. fontSize: "var(--vscode-font-size)",
  179. overflowY: isTextExpanded ? "auto" : "hidden",
  180. wordBreak: "break-word",
  181. overflowWrap: "anywhere",
  182. position: "relative",
  183. }}>
  184. <div
  185. ref={textRef}
  186. style={{
  187. display: "-webkit-box",
  188. WebkitLineClamp: isTextExpanded ? "unset" : 3,
  189. WebkitBoxOrient: "vertical",
  190. overflow: "hidden",
  191. whiteSpace: "pre-wrap",
  192. wordBreak: "break-word",
  193. overflowWrap: "anywhere",
  194. }}>
  195. {highlightMentions(task.text, false)}
  196. </div>
  197. {!isTextExpanded && showSeeMore && (
  198. <div
  199. style={{
  200. position: "absolute",
  201. right: 0,
  202. bottom: 0,
  203. display: "flex",
  204. alignItems: "center",
  205. }}>
  206. <div
  207. style={{
  208. width: 30,
  209. height: "1.2em",
  210. background:
  211. "linear-gradient(to right, transparent, var(--vscode-badge-background))",
  212. }}
  213. />
  214. <div
  215. style={{
  216. cursor: "pointer",
  217. color: "var(--vscode-textLink-foreground)",
  218. paddingRight: 0,
  219. paddingLeft: 3,
  220. backgroundColor: "var(--vscode-badge-background)",
  221. }}
  222. onClick={() => setIsTextExpanded(!isTextExpanded)}>
  223. See more
  224. </div>
  225. </div>
  226. )}
  227. </div>
  228. {isTextExpanded && showSeeMore && (
  229. <div
  230. style={{
  231. cursor: "pointer",
  232. color: "var(--vscode-textLink-foreground)",
  233. marginLeft: "auto",
  234. textAlign: "right",
  235. paddingRight: 2,
  236. }}
  237. onClick={() => setIsTextExpanded(!isTextExpanded)}>
  238. See less
  239. </div>
  240. )}
  241. {task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
  242. <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
  243. <div className="flex justify-between items-center h-[20px]">
  244. <div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
  245. <span style={{ fontWeight: "bold" }}>Tokens:</span>
  246. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  247. <i
  248. className="codicon codicon-arrow-up"
  249. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
  250. />
  251. {formatLargeNumber(tokensIn || 0)}
  252. </span>
  253. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  254. <i
  255. className="codicon codicon-arrow-down"
  256. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
  257. />
  258. {formatLargeNumber(tokensOut || 0)}
  259. </span>
  260. </div>
  261. {!isCostAvailable && <TaskActions item={currentTaskItem} />}
  262. </div>
  263. {isTaskExpanded && contextWindow && (
  264. <div className={`flex ${windowWidth < 270 ? "flex-col" : "flex-row"} gap-1 h-[20px]`}>
  265. <ContextWindowProgress
  266. contextWindow={contextWindow}
  267. contextTokens={contextTokens || 0}
  268. />
  269. </div>
  270. )}
  271. {shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
  272. <div className="flex items-center gap-1 flex-wrap h-[20px]">
  273. <span style={{ fontWeight: "bold" }}>Cache:</span>
  274. <span className="flex items-center gap-1">
  275. <i
  276. className="codicon codicon-database"
  277. style={{ fontSize: "12px", fontWeight: "bold" }}
  278. />
  279. +{formatLargeNumber(cacheWrites || 0)}
  280. </span>
  281. <span className="flex items-center gap-1">
  282. <i
  283. className="codicon codicon-arrow-right"
  284. style={{ fontSize: "12px", fontWeight: "bold" }}
  285. />
  286. {formatLargeNumber(cacheReads || 0)}
  287. </span>
  288. </div>
  289. )}
  290. {isCostAvailable && (
  291. <div className="flex justify-between items-center h-[20px]">
  292. <div className="flex items-center gap-1">
  293. <span className="font-bold">API Cost:</span>
  294. <span>${totalCost?.toFixed(4)}</span>
  295. </div>
  296. <TaskActions item={currentTaskItem} />
  297. </div>
  298. )}
  299. </div>
  300. </>
  301. )}
  302. </div>
  303. </div>
  304. )
  305. }
  306. export const highlightMentions = (text?: string, withShadow = true) => {
  307. if (!text) return text
  308. const parts = text.split(mentionRegexGlobal)
  309. return parts.map((part, index) => {
  310. if (index % 2 === 0) {
  311. // This is regular text
  312. return part
  313. } else {
  314. // This is a mention
  315. return (
  316. <span
  317. key={index}
  318. className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}
  319. style={{ cursor: "pointer" }}
  320. onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
  321. @{part}
  322. </span>
  323. )
  324. }
  325. })
  326. }
  327. const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
  328. <div className="flex flex-row gap-1">
  329. <Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
  330. <span className="codicon codicon-cloud-download" />
  331. </Button>
  332. {!!item?.size && item.size > 0 && (
  333. <Button
  334. variant="ghost"
  335. size="sm"
  336. onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: item.id })}>
  337. <span className="codicon codicon-trash" />
  338. {prettyBytes(item.size)}
  339. </Button>
  340. )}
  341. </div>
  342. )
  343. const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow: number; contextTokens: number }) => (
  344. <>
  345. <div className="flex items-center gap-1 flex-shrink-0">
  346. <span className="font-bold">Context Window:</span>
  347. </div>
  348. <div className="flex items-center gap-2 flex-1 whitespace-nowrap">
  349. <div>{formatLargeNumber(contextTokens)}</div>
  350. <div className="flex items-center gap-[3px] flex-1">
  351. <div className="flex-1 h-1 rounded-[2px] overflow-hidden bg-[color-mix(in_srgb,var(--vscode-badge-foreground)_20%,transparent)]">
  352. <div
  353. className="h-full rounded-[2px] bg-[var(--vscode-badge-foreground)]"
  354. style={{
  355. width: `${(contextTokens / contextWindow) * 100}%`,
  356. transition: "width 0.3s ease-out",
  357. }}
  358. />
  359. </div>
  360. </div>
  361. <div>{formatLargeNumber(contextWindow)}</div>
  362. </div>
  363. </>
  364. )
  365. export default memo(TaskHeader)