diff --git a/webview-ui/src/components/Announcement.tsx b/webview-ui/src/components/Announcement.tsx index 5bf2092..ac91d72 100644 --- a/webview-ui/src/components/Announcement.tsx +++ b/webview-ui/src/components/Announcement.tsx @@ -1,5 +1,6 @@ import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { ApiConfiguration } from "../../../src/shared/api" +import { memo } from "react" // import VSCodeButtonLink from "./VSCodeButtonLink" // import { getOpenRouterAuthUrl } from "./ApiOptions" // import { vscode } from "../utils/vscode" @@ -89,4 +90,4 @@ const Announcement = ({ version, hideAnnouncement, apiConfiguration, vscodeUriSc ) } -export default Announcement +export default memo(Announcement) diff --git a/webview-ui/src/components/ApiOptions.tsx b/webview-ui/src/components/ApiOptions.tsx index 0d87edf..f521c1d 100644 --- a/webview-ui/src/components/ApiOptions.tsx +++ b/webview-ui/src/components/ApiOptions.tsx @@ -6,7 +6,7 @@ import { VSCodeRadioGroup, VSCodeTextField, } from "@vscode/webview-ui-toolkit/react" -import React, { useCallback, useEffect, useMemo, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useState } from "react" import { ApiConfiguration, ModelInfo, @@ -31,7 +31,7 @@ interface ApiOptionsProps { apiErrorMessage?: string } -const ApiOptions: React.FC = ({ showModelOptions, apiErrorMessage }) => { +const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) @@ -550,4 +550,4 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { } } -export default ApiOptions +export default memo(ApiOptions) diff --git a/webview-ui/src/components/ChatRow.tsx b/webview-ui/src/components/ChatRow.tsx index eff54f4..079dd52 100644 --- a/webview-ui/src/components/ChatRow.tsx +++ b/webview-ui/src/components/ChatRow.tsx @@ -1,13 +1,13 @@ import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" -import React from "react" -import Markdown from "react-markdown" +import React, { memo, useMemo } from "react" +import ReactMarkdown from "react-markdown" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" -import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "../../../src/shared/ExtensionMessage" +import { ClaudeMessage, ClaudeSayTool } from "../../../src/shared/ExtensionMessage" import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences" import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme" import CodeBlock from "./CodeBlock" -import Thumbnails from "./Thumbnails" import Terminal from "./Terminal" +import Thumbnails from "./Thumbnails" interface ChatRowProps { message: ClaudeMessage @@ -19,7 +19,21 @@ interface ChatRowProps { handleSendStdin: (text: string) => void } -const ChatRow: React.FC = ({ +const ChatRow = memo((props: ChatRowProps) => { + // we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered + return ( +
+ +
+ ) +}) + +export default ChatRow + +const ChatRowContent = ({ message, syntaxHighlighterStyle, isExpanded, @@ -27,35 +41,26 @@ const ChatRow: React.FC = ({ lastModifiedMessage, isLast, handleSendStdin, -}) => { - const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined +}: ChatRowProps) => { + const cost = useMemo(() => { + if (message.text != null && message.say === "api_req_started") { + return JSON.parse(message.text).cost + } + return undefined + }, [message.text, message.say]) const apiRequestFailedMessage = isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried ? lastModifiedMessage?.text : undefined const isCommandExecuting = isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING) + const type = message.type === "ask" ? message.ask : message.say - const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, JSX.Element | null] => { - const normalColor = "var(--vscode-foreground)" - const errorColor = "var(--vscode-errorForeground)" - const successColor = "var(--vscode-charts-green)" - - const ProgressIndicator = ( -
-
- -
-
- ) + const normalColor = "var(--vscode-foreground)" + const errorColor = "var(--vscode-errorForeground)" + const successColor = "var(--vscode-charts-green)" + const [icon, title] = useMemo(() => { switch (type) { case "error": return [ @@ -74,7 +79,7 @@ const ChatRow: React.FC = ({ case "command": return [ isCommandExecuting ? ( - ProgressIndicator + ) : ( = ({ className="codicon codicon-error" style={{ color: errorColor, marginBottom: "-1.5px" }}> ) : ( - ProgressIndicator + ), cost != null ? ( API Request Complete @@ -122,366 +127,30 @@ const ChatRow: React.FC = ({ default: return [null, null] } + }, [type, cost, apiRequestFailedMessage, isCommandExecuting]) + + const headerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "10px", + marginBottom: "10px", } - const renderMarkdown = (markdown: string = "") => { - // react-markdown lets us customize elements, so here we're using their example of replacing code blocks with SyntaxHighlighter. However when there are no language matches (` or ``` without a language specifier) then we default to a normal code element for inline code. Code blocks without a language specifier shouldn't be a common occurrence as we prompt Claude to always use a language specifier. - // when claude wraps text in thinking tags, he doesnt use line breaks so we need to insert those ourselves to render markdown correctly - const parsed = markdown.replace(/([\s\S]*?)<\/thinking>/g, (match, content) => { - return `__\n\n${content}\n\n__` - }) - return ( -
- - ) - }, - ol(props) { - const { style, ...rest } = props - return ( -
    - ) - }, - ul(props) { - const { style, ...rest } = props - return ( -
      - ) - }, - // https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight - code(props) { - const { children, className, node, ...rest } = props - const match = /language-(\w+)/.exec(className || "") - return match ? ( - - ) : ( - - {children} - - ) - }, - }} - /> -
- ) + const pStyle: React.CSSProperties = { + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + overflowWrap: "anywhere", } - const renderContent = () => { - const [icon, title] = getIconAndTitle(message.type === "ask" ? message.ask : message.say) - - const headerStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: "10px", - marginBottom: "10px", + const tool = useMemo(() => { + if (message.ask === "tool" || message.say === "tool") { + return JSON.parse(message.text || "{}") as ClaudeSayTool } + return null + }, [message.ask, message.say, message.text]) - const pStyle: React.CSSProperties = { - margin: 0, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - overflowWrap: "anywhere", - } - - switch (message.type) { - case "say": - switch (message.say) { - case "api_req_started": - return ( - <> -
-
- {icon} - {title} - {cost != null && cost > 0 && ( - ${Number(cost)?.toFixed(4)} - )} -
- - - -
- {cost == null && apiRequestFailedMessage && ( - <> -

- {apiRequestFailedMessage} -

- {/* {apiProvider === "kodu" && ( -
- - - Uh-oh, this could be a problem on Kodu's end. We've been alerted and - will resolve this ASAP. You can also{" "} - - contact us on discord - - . - -
- )} */} - - )} - - ) - case "api_req_finished": - return null // we should never see this message type - case "text": - return
{renderMarkdown(message.text)}
- case "user_feedback": - return ( -
- {message.text} - {message.images && message.images.length > 0 && ( - - )} -
- ) - case "user_feedback_diff": - const tool = JSON.parse(message.text || "{}") as ClaudeSayTool - return ( -
- - The user made the following changes: - - -
- ) - case "error": - return ( - <> - {title && ( -
- {icon} - {title} -
- )} -

{message.text}

- - ) - case "completion_result": - return ( - <> -
- {icon} - {title} -
-
- {renderMarkdown(message.text)} -
- - ) - case "tool": - return renderTool(message, headerStyle) - default: - return ( - <> - {title && ( -
- {icon} - {title} -
- )} -
{renderMarkdown(message.text)}
- - ) - } - case "ask": - switch (message.ask) { - case "tool": - return renderTool(message, headerStyle) - case "mistake_limit_reached": - return ( - <> -
- {icon} - {title} -
-

{message.text}

- - ) - case "command": - const splitMessage = (text: string) => { - const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING) - if (outputIndex === -1) { - return { command: text, output: "" } - } - return { - command: text.slice(0, outputIndex).trim(), - output: text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim() + " ", - } - } - - const { command, output } = splitMessage(message.text || "") - return ( - <> -
- {icon} - {title} -
- 0} - /> - - ) - case "completion_result": - if (message.text) { - return ( -
-
- {icon} - {title} -
-
- {renderMarkdown(message.text)} -
-
- ) - } else { - return null // Don't render anything when we get a completion_result ask without text - } - case "followup": - return ( - <> - {title && ( -
- {icon} - {title} -
- )} -
{renderMarkdown(message.text)}
- - ) - } - } - } - - const renderTool = (message: ClaudeMessage, headerStyle: React.CSSProperties) => { - const tool = JSON.parse(message.text || "{}") as ClaudeSayTool + if (tool) { const toolIcon = (name: string) => ( = ({ } } - // NOTE: we cannot return null as virtuoso does not support it, so we must use a separate visibleMessages array to filter out messages that should not be rendered + switch (message.type) { + case "say": + switch (message.say) { + case "api_req_started": + return ( + <> +
+
+ {icon} + {title} + {cost != null && cost > 0 && ${Number(cost)?.toFixed(4)}} +
+ + + +
+ {cost == null && apiRequestFailedMessage && ( + <> +

+ {apiRequestFailedMessage} +

+ {/* {apiProvider === "kodu" && ( +
+ + + Uh-oh, this could be a problem on Kodu's end. We've been alerted and + will resolve this ASAP. You can also{" "} + + contact us on discord + + . + +
+ )} */} + + )} - return ( -
- {renderContent()} - {isExpanded && message.say === "api_req_started" && ( -
- -
- )} -
- ) + {isExpanded && ( +
+ +
+ )} + + ) + case "api_req_finished": + return null // we should never see this message type + case "text": + return ( +
+ +
+ ) + case "user_feedback": + return ( +
+ {message.text} + {message.images && message.images.length > 0 && ( + + )} +
+ ) + case "user_feedback_diff": + const tool = JSON.parse(message.text || "{}") as ClaudeSayTool + return ( +
+ + The user made the following changes: + + +
+ ) + case "error": + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +

{message.text}

+ + ) + case "completion_result": + return ( + <> +
+ {icon} + {title} +
+
+ +
+ + ) + + default: + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +
+ +
+ + ) + } + case "ask": + switch (message.ask) { + case "mistake_limit_reached": + return ( + <> +
+ {icon} + {title} +
+

{message.text}

+ + ) + case "command": + const splitMessage = (text: string) => { + const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING) + if (outputIndex === -1) { + return { command: text, output: "" } + } + return { + command: text.slice(0, outputIndex).trim(), + output: text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim() + " ", + } + } + + const { command, output } = splitMessage(message.text || "") + return ( + <> +
+ {icon} + {title} +
+ 0} + /> + + ) + case "completion_result": + if (message.text) { + return ( +
+
+ {icon} + {title} +
+
+ +
+
+ ) + } else { + return null // Don't render anything when we get a completion_result ask without text + } + case "followup": + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +
+ +
+ + ) + default: + return null + } + } } -export default ChatRow +const ProgressIndicator = () => ( +
+
+ +
+
+) + +const Markdown = memo( + ({ syntaxHighlighterStyle, markdown }: { syntaxHighlighterStyle: SyntaxHighlighterStyle; markdown?: string }) => { + // react-markdown lets us customize elements, so here we're using their example of replacing code blocks with SyntaxHighlighter. However when there are no language matches (` or ``` without a language specifier) then we default to a normal code element for inline code. Code blocks without a language specifier shouldn't be a common occurrence as we prompt Claude to always use a language specifier. + // when claude wraps text in thinking tags, he doesnt use line breaks so we need to insert those ourselves to render markdown correctly + const parsed = markdown?.replace(/([\s\S]*?)<\/thinking>/g, (match, content) => { + return `__\n\n${content}\n\n__` + }) + return ( +
+ + ) + }, + ol(props) { + const { style, ...rest } = props + return ( +
    + ) + }, + ul(props) { + const { style, ...rest } = props + return ( +
      + ) + }, + // https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight + code(props) { + const { children, className, node, ...rest } = props + const match = /language-(\w+)/.exec(className || "") + return match ? ( + + ) : ( + + {children} + + ) + }, + }} + /> +
+ ) + } +) diff --git a/webview-ui/src/components/ChatView.tsx b/webview-ui/src/components/ChatView.tsx index 9c6548a..d19ee5d 100644 --- a/webview-ui/src/components/ChatView.tsx +++ b/webview-ui/src/components/ChatView.tsx @@ -215,7 +215,7 @@ const ChatView = ({ } }, [messages.length]) - const handleSendMessage = () => { + const handleSendMessage = useCallback(() => { const text = inputValue.trim() if (text || selectedImages.length > 0) { if (messages.length === 0) { @@ -248,26 +248,33 @@ const ChatView = ({ // setPrimaryButtonText(undefined) // setSecondaryButtonText(undefined) } - } + }, [inputValue, selectedImages, messages.length, claudeAsk]) - const handleSendStdin = (text: string) => { - if (claudeAsk === "command_output") { - vscode.postMessage({ - type: "askResponse", - askResponse: "messageResponse", - text: COMMAND_STDIN_STRING + text, - }) - setClaudeAsk(undefined) - // don't need to disable since extension relinquishes control back immediately - // setTextAreaDisabled(true) - // setEnableButtons(false) - } - } + const handleSendStdin = useCallback( + (text: string) => { + if (claudeAsk === "command_output") { + vscode.postMessage({ + type: "askResponse", + askResponse: "messageResponse", + text: COMMAND_STDIN_STRING + text, + }) + setClaudeAsk(undefined) + // don't need to disable since extension relinquishes control back immediately + // setTextAreaDisabled(true) + // setEnableButtons(false) + } + }, + [claudeAsk] + ) + + const startNewTask = useCallback(() => { + vscode.postMessage({ type: "clearTask" }) + }, []) /* This logic depends on the useEffect[messages] above to set claudeAsk, after which buttons are shown and we then send an askResponse to the extension. */ - const handlePrimaryButtonClick = () => { + const handlePrimaryButtonClick = useCallback(() => { switch (claudeAsk) { case "api_req_failed": case "command": @@ -288,9 +295,9 @@ const ChatView = ({ setEnableButtons(false) // setPrimaryButtonText(undefined) // setSecondaryButtonText(undefined) - } + }, [claudeAsk, startNewTask]) - const handleSecondaryButtonClick = () => { + const handleSecondaryButtonClick = useCallback(() => { switch (claudeAsk) { case "api_req_failed": case "mistake_limit_reached": @@ -307,67 +314,72 @@ const ChatView = ({ setEnableButtons(false) // setPrimaryButtonText(undefined) // setSecondaryButtonText(undefined) - } + }, [claudeAsk, startNewTask]) - const handleKeyDown = (event: KeyboardEvent) => { - const isComposing = event.nativeEvent?.isComposing ?? false - if (event.key === "Enter" && !event.shiftKey && !isComposing) { - event.preventDefault() - handleSendMessage() - } - } - - const handleTaskCloseButtonClick = () => { - startNewTask() - } - - const startNewTask = () => { - vscode.postMessage({ type: "clearTask" }) - } - - const selectImages = () => { - vscode.postMessage({ type: "selectImages" }) - } - - const handlePaste = async (e: React.ClipboardEvent) => { - const items = e.clipboardData.items - const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg) - const imageItems = Array.from(items).filter((item) => { - const [type, subtype] = item.type.split("/") - return type === "image" && acceptedTypes.includes(subtype) - }) - if (!shouldDisableImages && imageItems.length > 0) { - e.preventDefault() - const imagePromises = imageItems.map((item) => { - return new Promise((resolve) => { - const blob = item.getAsFile() - if (!blob) { - resolve(null) - return - } - const reader = new FileReader() - reader.onloadend = () => { - if (reader.error) { - console.error("Error reading file:", reader.error) - resolve(null) - } else { - const result = reader.result - resolve(typeof result === "string" ? result : null) - } - } - reader.readAsDataURL(blob) - }) - }) - const imageDataArray = await Promise.all(imagePromises) - const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) - //.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it - if (dataUrls.length > 0) { - setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) - } else { - console.warn("No valid images were processed") + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const isComposing = event.nativeEvent?.isComposing ?? false + if (event.key === "Enter" && !event.shiftKey && !isComposing) { + event.preventDefault() + handleSendMessage() } - } - } + }, + [handleSendMessage] + ) + + const handleTaskCloseButtonClick = useCallback(() => { + startNewTask() + }, [startNewTask]) + + const selectImages = useCallback(() => { + vscode.postMessage({ type: "selectImages" }) + }, []) + + const shouldDisableImages = + !selectedModelSupportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const items = e.clipboardData.items + const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg) + const imageItems = Array.from(items).filter((item) => { + const [type, subtype] = item.type.split("/") + return type === "image" && acceptedTypes.includes(subtype) + }) + if (!shouldDisableImages && imageItems.length > 0) { + e.preventDefault() + const imagePromises = imageItems.map((item) => { + return new Promise((resolve) => { + const blob = item.getAsFile() + if (!blob) { + resolve(null) + return + } + const reader = new FileReader() + reader.onloadend = () => { + if (reader.error) { + console.error("Error reading file:", reader.error) + resolve(null) + } else { + const result = reader.result + resolve(typeof result === "string" ? result : null) + } + } + reader.readAsDataURL(blob) + }) + }) + const imageDataArray = await Promise.all(imagePromises) + const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) + //.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it + if (dataUrls.length > 0) { + setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) + } else { + console.warn("No valid images were processed") + } + } + }, + [shouldDisableImages, setSelectedImages] + ) useEffect(() => { if (selectedImages.length === 0) { @@ -469,8 +481,21 @@ const ChatView = ({ return text }, [task]) - const shouldDisableImages = - !selectedModelSupportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE + const itemContent = useCallback( + (index: number, message: any) => ( + toggleRowExpansion(message.ts)} + lastModifiedMessage={modifiedMessages.at(-1)} + isLast={index === visibleMessages.length - 1} + handleSendStdin={handleSendStdin} + /> + ), + [expandedRows, syntaxHighlighterStyle, modifiedMessages, visibleMessages.length, handleSendStdin] + ) return (
( - toggleRowExpansion(message.ts)} - lastModifiedMessage={modifiedMessages.at(-1)} - isLast={index === visibleMessages.length - 1} - handleSendStdin={handleSendStdin} - /> - )} + itemContent={itemContent} />
void } +/* +We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work. +^: Anchors the match to the start of the string. +[^a-zA-Z0-9]+: Matches one or more characters that are not alphanumeric. +The replace method removes these matched characters, effectively trimming the string up to the first alphanumeric character. +*/ +const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[^a-zA-Z0-9]+/, "") + const CodeBlock = ({ code, diff, @@ -76,15 +84,6 @@ const CodeBlock = ({ isExpanded, onToggleExpand, }: CodeBlockProps) => { - /* - We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work. - - ^: Anchors the match to the start of the string. - [^a-zA-Z0-9]+: Matches one or more characters that are not alphanumeric. - The replace method removes these matched characters, effectively trimming the string up to the first alphanumeric character. - */ - const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[^a-zA-Z0-9]+/, "") - const inferredLanguage = useMemo( () => code && (language ?? (path ? getLanguageFromPath(path) : undefined)), [path, language, code] @@ -168,5 +167,5 @@ const CodeBlock = ({
) } - -export default CodeBlock +// memo does shallow comparison of props, so if you need it to re-render when a nested object changes, you need to pass a custom comparison function +export default memo(CodeBlock) diff --git a/webview-ui/src/components/HistoryPreview.tsx b/webview-ui/src/components/HistoryPreview.tsx index 24d7023..a9aa2c7 100644 --- a/webview-ui/src/components/HistoryPreview.tsx +++ b/webview-ui/src/components/HistoryPreview.tsx @@ -1,6 +1,7 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { useExtensionState } from "../context/ExtensionStateContext" import { vscode } from "../utils/vscode" +import { memo } from "react" type HistoryPreviewProps = { showHistoryView: () => void @@ -148,4 +149,4 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { ) } -export default HistoryPreview +export default memo(HistoryPreview) diff --git a/webview-ui/src/components/HistoryView.tsx b/webview-ui/src/components/HistoryView.tsx index af15635..50102cf 100644 --- a/webview-ui/src/components/HistoryView.tsx +++ b/webview-ui/src/components/HistoryView.tsx @@ -2,7 +2,7 @@ import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { useExtensionState } from "../context/ExtensionStateContext" import { vscode } from "../utils/vscode" import { Virtuoso } from "react-virtuoso" -import { useMemo, useState } from "react" +import { memo, useMemo, useState } from "react" type HistoryViewProps = { onDone: () => void @@ -20,10 +20,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { vscode.postMessage({ type: "deleteTaskWithId", text: id }) } - const handleExportMd = (id: string) => { - vscode.postMessage({ type: "exportTaskWithId", text: id }) - } - const formatDate = (timestamp: number) => { const date = new Date(timestamp) return date @@ -63,18 +59,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { ) } - const ExportButton = ({ itemId }: { itemId: string }) => ( - { - e.stopPropagation() - handleExportMd(itemId) - }}> -
EXPORT
-
- ) - return ( <>