TaskHeader.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
  2. import React, { memo, useEffect, useRef, useState } from "react"
  3. import { useWindowSize } from "react-use"
  4. import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
  5. import { useExtensionState } from "../context/ExtensionStateContext"
  6. import { vscode } from "../utils/vscode"
  7. import Thumbnails from "./Thumbnails"
  8. interface TaskHeaderProps {
  9. task: ClaudeMessage
  10. tokensIn: number
  11. tokensOut: number
  12. doesModelSupportPromptCache: boolean
  13. cacheWrites?: number
  14. cacheReads?: number
  15. totalCost: number
  16. onClose: () => void
  17. }
  18. const TaskHeader: React.FC<TaskHeaderProps> = ({
  19. task,
  20. tokensIn,
  21. tokensOut,
  22. doesModelSupportPromptCache,
  23. cacheWrites,
  24. cacheReads,
  25. totalCost,
  26. onClose,
  27. }) => {
  28. const { apiConfiguration } = useExtensionState()
  29. const [isTaskExpanded, setIsTaskExpanded] = useState(true)
  30. const [isTextExpanded, setIsTextExpanded] = useState(false)
  31. const [showSeeMore, setShowSeeMore] = useState(false)
  32. const textContainerRef = useRef<HTMLDivElement>(null)
  33. const textRef = useRef<HTMLDivElement>(null)
  34. /*
  35. 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.
  36. Sources
  37. - https://usehooks-ts.com/react-hook/use-event-listener
  38. - https://streamich.github.io/react-use/?path=/story/sensors-useevent--docs
  39. - https://github.com/streamich/react-use/blob/master/src/useEvent.ts
  40. - https://stackoverflow.com/questions/55565444/how-to-register-event-with-useeffect-hooks
  41. Before:
  42. const updateMaxHeight = useCallback(() => {
  43. if (isExpanded && textContainerRef.current) {
  44. const maxHeight = window.innerHeight * (3 / 5)
  45. textContainerRef.current.style.maxHeight = `${maxHeight}px`
  46. }
  47. }, [isExpanded])
  48. useEffect(() => {
  49. updateMaxHeight()
  50. }, [isExpanded, updateMaxHeight])
  51. useEffect(() => {
  52. window.removeEventListener("resize", updateMaxHeight)
  53. window.addEventListener("resize", updateMaxHeight)
  54. return () => {
  55. window.removeEventListener("resize", updateMaxHeight)
  56. }
  57. }, [updateMaxHeight])
  58. After:
  59. */
  60. const { height: windowHeight, width: windowWidth } = useWindowSize()
  61. useEffect(() => {
  62. if (isTextExpanded && textContainerRef.current) {
  63. const maxHeight = windowHeight * (1 / 2)
  64. textContainerRef.current.style.maxHeight = `${maxHeight}px`
  65. }
  66. }, [isTextExpanded, windowHeight])
  67. useEffect(() => {
  68. if (textRef.current && textContainerRef.current) {
  69. let textContainerHeight = textContainerRef.current.clientHeight
  70. if (!textContainerHeight) {
  71. textContainerHeight = textContainerRef.current.getBoundingClientRect().height
  72. }
  73. const isOverflowing = textRef.current.scrollHeight > textContainerHeight
  74. // necessary to show see more button again if user resizes window to expand and then back to collapse
  75. if (!isOverflowing) {
  76. setIsTextExpanded(false)
  77. }
  78. setShowSeeMore(isOverflowing)
  79. }
  80. }, [task.text, windowWidth])
  81. return (
  82. <div style={{ padding: "10px 13px 10px 13px" }}>
  83. <div
  84. style={{
  85. backgroundColor: "var(--vscode-badge-background)",
  86. color: "var(--vscode-badge-foreground)",
  87. borderRadius: "3px",
  88. padding: "9px 10px 9px 14px",
  89. display: "flex",
  90. flexDirection: "column",
  91. gap: 6,
  92. position: "relative",
  93. zIndex: 1,
  94. }}>
  95. <div
  96. style={{
  97. display: "flex",
  98. justifyContent: "space-between",
  99. alignItems: "center",
  100. }}>
  101. <div
  102. style={{
  103. display: "flex",
  104. alignItems: "center",
  105. cursor: "pointer",
  106. marginLeft: -2,
  107. userSelect: "none",
  108. WebkitUserSelect: "none",
  109. MozUserSelect: "none",
  110. msUserSelect: "none",
  111. flexGrow: 1,
  112. minWidth: 0, // This allows the div to shrink below its content size
  113. }}
  114. onClick={() => setIsTaskExpanded(!isTaskExpanded)}>
  115. <div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
  116. <span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
  117. </div>
  118. <div
  119. style={{
  120. marginLeft: 6,
  121. whiteSpace: "nowrap",
  122. overflow: "hidden",
  123. textOverflow: "ellipsis",
  124. flexGrow: 1,
  125. minWidth: 0, // This allows the div to shrink below its content size
  126. }}>
  127. <span style={{ fontWeight: "bold" }}>Task{!isTaskExpanded && ":"}</span>
  128. {!isTaskExpanded && <span style={{ marginLeft: 4 }}>{task.text}</span>}
  129. </div>
  130. </div>
  131. {!isTaskExpanded &&
  132. apiConfiguration?.apiProvider !== "openai" &&
  133. apiConfiguration?.apiProvider !== "ollama" && (
  134. <div
  135. style={{
  136. marginLeft: 10,
  137. backgroundColor:
  138. "color-mix(in srgb, var(--vscode-badge-foreground) 70%, transparent)",
  139. color: "var(--vscode-badge-background)",
  140. padding: "2px 4px",
  141. borderRadius: "500px",
  142. fontSize: "11px",
  143. fontWeight: 500,
  144. display: "inline-block",
  145. flexShrink: 0,
  146. }}>
  147. ${totalCost?.toFixed(4)}
  148. </div>
  149. )}
  150. <VSCodeButton appearance="icon" onClick={onClose} style={{ marginLeft: 6, flexShrink: 0 }}>
  151. <span className="codicon codicon-close"></span>
  152. </VSCodeButton>
  153. </div>
  154. {isTaskExpanded && (
  155. <>
  156. <div
  157. ref={textContainerRef}
  158. style={{
  159. marginTop: -2,
  160. fontSize: "var(--vscode-font-size)",
  161. overflowY: isTextExpanded ? "auto" : "hidden",
  162. wordBreak: "break-word",
  163. overflowWrap: "anywhere",
  164. position: "relative",
  165. }}>
  166. <div
  167. ref={textRef}
  168. style={{
  169. display: "-webkit-box",
  170. WebkitLineClamp: isTextExpanded ? "unset" : 3,
  171. WebkitBoxOrient: "vertical",
  172. overflow: "hidden",
  173. whiteSpace: "pre-wrap",
  174. wordBreak: "break-word",
  175. overflowWrap: "anywhere",
  176. }}>
  177. {task.text}
  178. </div>
  179. {!isTextExpanded && showSeeMore && (
  180. <div
  181. style={{
  182. position: "absolute",
  183. right: 0,
  184. bottom: 0,
  185. display: "flex",
  186. alignItems: "center",
  187. }}>
  188. <div
  189. style={{
  190. width: 30,
  191. height: "1.2em",
  192. background:
  193. "linear-gradient(to right, transparent, var(--vscode-badge-background))",
  194. }}
  195. />
  196. <div
  197. style={{
  198. cursor: "pointer",
  199. color: "var(--vscode-textLink-foreground)",
  200. paddingRight: 0,
  201. paddingLeft: 3,
  202. backgroundColor: "var(--vscode-badge-background)",
  203. }}
  204. onClick={() => setIsTextExpanded(!isTextExpanded)}>
  205. See more
  206. </div>
  207. </div>
  208. )}
  209. </div>
  210. {isTextExpanded && showSeeMore && (
  211. <div
  212. style={{
  213. cursor: "pointer",
  214. color: "var(--vscode-textLink-foreground)",
  215. marginLeft: "auto",
  216. textAlign: "right",
  217. paddingRight: 2,
  218. }}
  219. onClick={() => setIsTextExpanded(!isTextExpanded)}>
  220. See less
  221. </div>
  222. )}
  223. {task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
  224. <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
  225. <div
  226. style={{
  227. display: "flex",
  228. justifyContent: "space-between",
  229. alignItems: "center",
  230. }}>
  231. <div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
  232. <span style={{ fontWeight: "bold" }}>Tokens:</span>
  233. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  234. <i
  235. className="codicon codicon-arrow-up"
  236. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
  237. />
  238. {tokensIn?.toLocaleString()}
  239. </span>
  240. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  241. <i
  242. className="codicon codicon-arrow-down"
  243. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
  244. />
  245. {tokensOut?.toLocaleString()}
  246. </span>
  247. </div>
  248. {(apiConfiguration?.apiProvider === "openai" ||
  249. apiConfiguration?.apiProvider === "ollama") && <ExportButton />}
  250. </div>
  251. {(doesModelSupportPromptCache || cacheReads !== undefined || cacheWrites !== undefined) && (
  252. <div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
  253. <span style={{ fontWeight: "bold" }}>Cache:</span>
  254. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  255. <i
  256. className="codicon codicon-database"
  257. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1px" }}
  258. />
  259. +{(cacheWrites || 0)?.toLocaleString()}
  260. </span>
  261. <span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
  262. <i
  263. className="codicon codicon-arrow-right"
  264. style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
  265. />
  266. {(cacheReads || 0)?.toLocaleString()}
  267. </span>
  268. </div>
  269. )}
  270. {apiConfiguration?.apiProvider !== "openai" &&
  271. apiConfiguration?.apiProvider !== "ollama" && (
  272. <div
  273. style={{
  274. display: "flex",
  275. justifyContent: "space-between",
  276. alignItems: "center",
  277. }}>
  278. <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
  279. <span style={{ fontWeight: "bold" }}>API Cost:</span>
  280. <span>${totalCost?.toFixed(4)}</span>
  281. </div>
  282. <ExportButton />
  283. </div>
  284. )}
  285. </div>
  286. </>
  287. )}
  288. </div>
  289. {/* {apiProvider === "kodu" && (
  290. <div
  291. style={{
  292. backgroundColor: "color-mix(in srgb, var(--vscode-badge-background) 50%, transparent)",
  293. color: "var(--vscode-badge-foreground)",
  294. borderRadius: "0 0 3px 3px",
  295. display: "flex",
  296. justifyContent: "space-between",
  297. alignItems: "center",
  298. padding: "4px 12px 6px 12px",
  299. fontSize: "0.9em",
  300. marginLeft: "10px",
  301. marginRight: "10px",
  302. }}>
  303. <div style={{ fontWeight: "500" }}>Credits Remaining:</div>
  304. <div>
  305. {formatPrice(koduCredits || 0)}
  306. {(koduCredits || 0) < 1 && (
  307. <>
  308. {" "}
  309. <VSCodeLink style={{ fontSize: "0.9em" }} href={getKoduAddCreditsUrl(vscodeUriScheme)}>
  310. (get more?)
  311. </VSCodeLink>
  312. </>
  313. )}
  314. </div>
  315. </div>
  316. )} */}
  317. </div>
  318. )
  319. }
  320. const ExportButton = () => (
  321. <VSCodeButton
  322. appearance="icon"
  323. onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
  324. style={
  325. {
  326. // marginBottom: "-2px",
  327. // marginRight: "-2.5px",
  328. }
  329. }>
  330. <div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT</div>
  331. </VSCodeButton>
  332. )
  333. export default memo(TaskHeader)