Add ChatRow and handle different message types

This commit is contained in:
Saoud Rizwan
2024-07-08 15:37:50 -04:00
parent e713212e8c
commit 2ab7873e9c
4 changed files with 331 additions and 76 deletions

View File

@@ -11,7 +11,7 @@ import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants"
import { Tool, ToolName } from "./shared/Tool" import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAsk, ClaudeSay, ExtensionMessage } from "./shared/ExtensionMessage" import { ClaudeAsk, ClaudeSay, ExtensionMessage } from "./shared/ExtensionMessage"
import * as vscode from "vscode" import * as vscode from "vscode"
import pWaitFor from 'p-wait-for' import pWaitFor from "p-wait-for"
import { ClaudeAskResponse } from "./shared/WebviewMessage" import { ClaudeAskResponse } from "./shared/WebviewMessage"
import { SidebarProvider } from "./providers/SidebarProvider" import { SidebarProvider } from "./providers/SidebarProvider"
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult" import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
@@ -159,51 +159,51 @@ export class ClaudeDev {
private conversationHistory: Anthropic.MessageParam[] = [] private conversationHistory: Anthropic.MessageParam[] = []
private maxRequestsPerTask: number private maxRequestsPerTask: number
private requestCount = 0 private requestCount = 0
private askResponse?: ClaudeAskResponse private askResponse?: ClaudeAskResponse
private askResponseText?: string private askResponseText?: string
private providerRef: WeakRef<SidebarProvider> private providerRef: WeakRef<SidebarProvider>
constructor(provider: SidebarProvider, task: string, apiKey: string, maxRequestsPerTask?: number) { constructor(provider: SidebarProvider, task: string, apiKey: string, maxRequestsPerTask?: number) {
this.providerRef = new WeakRef(provider) this.providerRef = new WeakRef(provider)
this.client = new Anthropic({ apiKey }) this.client = new Anthropic({ apiKey })
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
this.startTask(task) this.startTask(task)
} }
updateApiKey(apiKey: string) { updateApiKey(apiKey: string) {
this.client = new Anthropic({ apiKey }) this.client = new Anthropic({ apiKey })
}
updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
}
async handleWebviewAskResponse(askResponse: ClaudeAskResponse, text?: string) {
this.askResponse = askResponse
this.askResponseText = text
}
async ask(type: ClaudeAsk, question: string): Promise<{response: ClaudeAskResponse, text?: string}> {
this.askResponse = undefined
this.askResponseText = undefined
await this.providerRef.deref()?.addClaudeMessage({ type: "ask", ask: type, text: question })
await this.providerRef.deref()?.postStateToWebview()
await pWaitFor(() => this.askResponse !== undefined, { interval: 100 })
const result = { response: this.askResponse!, text: this.askResponseText }
this.askResponse = undefined
this.askResponseText = undefined
return result
} }
async say(type: ClaudeSay, question: string): Promise<undefined> { updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
await this.providerRef.deref()?.addClaudeMessage({ type: "say", say: type, text: question }) this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
await this.providerRef.deref()?.postStateToWebview() }
async handleWebviewAskResponse(askResponse: ClaudeAskResponse, text?: string) {
this.askResponse = askResponse
this.askResponseText = text
}
async ask(type: ClaudeAsk, question: string): Promise<{ response: ClaudeAskResponse; text?: string }> {
this.askResponse = undefined
this.askResponseText = undefined
await this.providerRef.deref()?.addClaudeMessage({ ts: Date.now(), type: "ask", ask: type, text: question })
await this.providerRef.deref()?.postStateToWebview()
await pWaitFor(() => this.askResponse !== undefined, { interval: 100 })
const result = { response: this.askResponse!, text: this.askResponseText }
this.askResponse = undefined
this.askResponseText = undefined
return result
}
async say(type: ClaudeSay, text: string): Promise<undefined> {
await this.providerRef.deref()?.addClaudeMessage({ ts: Date.now(), type: "say", say: type, text: text })
await this.providerRef.deref()?.postStateToWebview()
} }
private async startTask(task: string): Promise<void> { private async startTask(task: string): Promise<void> {
// conversationHistory (for API) and claudeMessages (for webview) need to be in sync // conversationHistory (for API) and claudeMessages (for webview) need to be in sync
// if the extension process were killed, then on restart the claudeMessages might not be empty, so we need to set it to [] when we create a new ClaudeDev client (otherwise webview would show stale messages from previous session) // if the extension process were killed, then on restart the claudeMessages might not be empty, so we need to set it to [] when we create a new ClaudeDev client (otherwise webview would show stale messages from previous session)
await this.providerRef.deref()?.setClaudeMessages([]) await this.providerRef.deref()?.setClaudeMessages([])
await this.providerRef.deref()?.postStateToWebview() await this.providerRef.deref()?.postStateToWebview()
@@ -237,9 +237,9 @@ ${filesInCurrentDir}`
// The way this agentic loop works is that claude will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task. // The way this agentic loop works is that claude will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Claude is prompted to finish the task as efficiently as he can. // There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Claude is prompted to finish the task as efficiently as he can.
const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens) //const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
if (didCompleteTask) { if (didCompleteTask) {
this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`) //this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
break break
} else { } else {
this.say( this.say(
@@ -272,13 +272,13 @@ ${filesInCurrentDir}`
} }
// Calculates cost of a Claude 3.5 Sonnet API request // Calculates cost of a Claude 3.5 Sonnet API request
calculateApiCost(inputTokens: number, outputTokens: number): string { calculateApiCost(inputTokens: number, outputTokens: number): number {
const INPUT_COST_PER_MILLION = 3.0 // $3 per million input tokens const INPUT_COST_PER_MILLION = 3.0 // $3 per million input tokens
const OUTPUT_COST_PER_MILLION = 15.0 // $15 per million output tokens const OUTPUT_COST_PER_MILLION = 15.0 // $15 per million output tokens
const inputCost = (inputTokens / 1_000_000) * INPUT_COST_PER_MILLION const inputCost = (inputTokens / 1_000_000) * INPUT_COST_PER_MILLION
const outputCost = (outputTokens / 1_000_000) * OUTPUT_COST_PER_MILLION const outputCost = (outputTokens / 1_000_000) * OUTPUT_COST_PER_MILLION
const totalCost = inputCost + outputCost const totalCost = inputCost + outputCost
return `$${totalCost.toFixed(4)}` return totalCost
} }
async writeToFile(filePath: string, newContent: string): Promise<string> { async writeToFile(filePath: string, newContent: string): Promise<string> {
@@ -303,7 +303,7 @@ ${filesInCurrentDir}`
} }
} catch (error) { } catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}` const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString) this.say("error", errorString)
return errorString return errorString
} }
} }
@@ -312,16 +312,16 @@ ${filesInCurrentDir}`
try { try {
return await fs.readFile(filePath, "utf-8") return await fs.readFile(filePath, "utf-8")
} catch (error) { } catch (error) {
const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}` const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString) this.say("error", errorString)
return errorString return errorString
} }
} }
async listFiles(dirPath: string = "."): Promise<string> { async listFiles(dirPath: string = "."): Promise<string> {
// If the extension is run without a workspace open, we are in the root directory and don't want to list all files since it would prompt for permission to access everything // If the extension is run without a workspace open, we are in the root directory and don't want to list all files since it would prompt for permission to access everything
const cwd = process.cwd(); const cwd = process.cwd()
const root = process.platform === 'win32' ? path.parse(cwd).root : '/'; const root = process.platform === "win32" ? path.parse(cwd).root : "/"
const isRoot = cwd === root const isRoot = cwd === root
if (isRoot) { if (isRoot) {
return cwd return cwd
@@ -361,17 +361,20 @@ ${filesInCurrentDir}`
// FIXME: instead of using glob to read all files, we will use vscode api to get workspace files list. (otherwise this prompts user to give permissions to read files if e.g. it was opened at root directory) // FIXME: instead of using glob to read all files, we will use vscode api to get workspace files list. (otherwise this prompts user to give permissions to read files if e.g. it was opened at root directory)
return entries.slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself) return entries.slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself)
} catch (error) { } catch (error) {
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString) this.say("error", errorString)
return errorString return errorString
} }
} }
async executeCommand(command: string): Promise<string> { async executeCommand(command: string): Promise<string> {
const { response } = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve?`) const { response } = await this.ask(
if (response !== "yesButtonTapped") { "command",
return "Command execution was not approved by the user." `Claude wants to execute the following command:\n${command}\nDo you approve?`
} )
if (response !== "yesButtonTapped") {
return "Command execution was not approved by the user."
}
try { try {
let result = "" let result = ""
// execa by default tries to convery bash into javascript // execa by default tries to convery bash into javascript
@@ -385,8 +388,8 @@ ${filesInCurrentDir}`
} catch (e) { } catch (e) {
const error = e as any const error = e as any
let errorMessage = error.message || JSON.stringify(serializeError(error)) let errorMessage = error.message || JSON.stringify(serializeError(error))
const errorString = `Error executing command:\n${errorMessage}` const errorString = `Error executing command:\n${errorMessage}`
this.say("error", errorString) this.say("error", errorString)
return errorString return errorString
} }
} }
@@ -437,6 +440,7 @@ ${filesInCurrentDir}`
} }
try { try {
await this.say("api_req_started", JSON.stringify(userContent))
const response = await this.client.messages.create({ const response = await this.client.messages.create({
model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models
max_tokens: 4096, max_tokens: 4096,
@@ -450,7 +454,7 @@ ${filesInCurrentDir}`
let assistantResponses: Anthropic.Messages.ContentBlock[] = [] let assistantResponses: Anthropic.Messages.ContentBlock[] = []
let inputTokens = response.usage.input_tokens let inputTokens = response.usage.input_tokens
let outputTokens = response.usage.output_tokens let outputTokens = response.usage.output_tokens
await this.say("api_cost", `API request cost: ${this.calculateApiCost(inputTokens, outputTokens)}`) await this.say("api_req_finished", this.calculateApiCost(inputTokens, outputTokens).toString())
// A response always returns text content blocks (it's just that before we were iterating over the completion_attempt response before we could append text response, resulting in bug) // A response always returns text content blocks (it's just that before we were iterating over the completion_attempt response before we could append text response, resulting in bug)
for (const contentBlock of response.content) { for (const contentBlock of response.content) {

View File

@@ -9,6 +9,7 @@ export interface ExtensionMessage {
} }
export interface ClaudeMessage { export interface ClaudeMessage {
ts: number
type: "ask" | "say" type: "ask" | "say"
ask?: ClaudeAsk ask?: ClaudeAsk
say?: ClaudeSay say?: ClaudeSay
@@ -16,4 +17,4 @@ export interface ClaudeMessage {
} }
export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result" export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result"
export type ClaudeSay = "error" | "api_cost" | "text" | "tool" | "command_output" | "task_completed" export type ClaudeSay = "task" | "error" | "api_req_started" | "api_req_finished" | "text" | "tool" | "command_output"

View File

@@ -0,0 +1,209 @@
import React, { useState } from "react"
import { ClaudeMessage, ClaudeAsk, ClaudeSay } from "@shared/ExtensionMessage"
import { VSCodeButton, VSCodeProgressRing, VSCodeTag } from "@vscode/webview-ui-toolkit/react"
interface ChatRowProps {
message: ClaudeMessage
cost?: string
}
const ChatRow: React.FC<ChatRowProps> = ({ message, cost }) => {
const [isExpanded, setIsExpanded] = useState(false)
const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, string | null] => {
switch (type) {
case "request_limit_reached":
return [
<span className="codicon codicon-error" style={{ color: "var(--vscode-errorForeground)" }}></span>,
"Max Requests Reached",
]
case "error":
return [
<span className="codicon codicon-error" style={{ color: "var(--vscode-errorForeground)" }}></span>,
"Error",
]
case "command":
return [<span className="codicon codicon-terminal"></span>, "Command"]
case "completion_result":
return [
<span
className="codicon codicon-check"
style={{ color: "var(--vscode-testing-iconPassed)" }}></span>,
"Task Completed",
]
case "tool":
return [<span className="codicon codicon-tools"></span>, "Tool"]
default:
return [null, null]
}
}
const renderContent = () => {
const [icon, title] = getIconAndTitle(message.type === "ask" ? message.ask : message.say)
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "left",
gap: "10px",
marginBottom: "10px",
}
const contentStyle: React.CSSProperties = {
marginLeft: "20px",
}
switch (message.type) {
case "say":
switch (message.say) {
case "task":
return (
<div
style={{
backgroundColor: "var(--vscode-textBlockQuote-background)",
padding: "10px",
borderLeft: "5px solid var(--vscode-textBlockQuote-border)",
}}>
<h3 style={headerStyle}>Task</h3>
<p style={contentStyle}>{message.text}</p>
</div>
)
case "api_req_started":
return (
<div>
<div style={headerStyle}>
<span>Made API request...</span>
{cost ? (
<span
className="codicon codicon-check"
style={{ color: "var(--vscode-testing-iconPassed)" }}></span>
) : (
<VSCodeProgressRing />
)}
{cost && <VSCodeTag>{cost}</VSCodeTag>}
</div>
<VSCodeButton
appearance="icon"
aria-label="Toggle Details"
onClick={() => setIsExpanded(!isExpanded)}>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</VSCodeButton>
</div>
)
case "api_req_finished":
return null // Hide this message type
case "tool":
case "error":
case "text":
case "command_output":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
)}
<pre style={contentStyle}>
<code>{message.text}</code>
</pre>
</>
)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
)}
<p style={contentStyle}>{message.text}</p>
</>
)
}
case "ask":
switch (message.ask) {
case "request_limit_reached":
return (
<>
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
<p style={{ ...contentStyle, color: "var(--vscode-errorForeground)" }}>
Your task has reached the maximum request limit (maxRequestsPerTask, you can change
this in settings). Do you want to keep going or start a new task?
</p>
</>
)
case "command":
return (
<>
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
<div style={contentStyle}>
<p>Claude would like to run this command. Do you allow this?</p>
<pre>
<code>{message.text}</code>
</pre>
</div>
</>
)
case "completion_result":
return (
<div
style={{
borderLeft: "5px solid var(--vscode-testing-iconPassed)",
paddingLeft: "10px",
}}>
<div style={headerStyle}>
{icon}
<h4 style={{ color: "var(--vscode-testing-iconPassed)" }}>{title}</h4>
</div>
<p style={contentStyle}>{message.text}</p>
</div>
)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
)}
<p style={contentStyle}>{message.text}</p>
</>
)
}
}
}
if (message.say === "api_req_finished") {
return null // Don't render anything for this message type
}
return (
<div
style={{
padding: "10px",
borderBottom: "1px solid var(--vscode-panel-border)",
backgroundColor:
message.say === "task"
? "var(--vscode-textBlockQuote-background)"
: "var(--vscode-editor-background)",
}}>
{renderContent()}
{isExpanded && message.say === "api_req_started" && (
<pre style={{ marginTop: "10px" }}>
<code>{message.text}</code>
</pre>
)}
</div>
)
}
export default ChatRow

View File

@@ -4,12 +4,60 @@ import { KeyboardEvent, useEffect, useRef, useState } from "react"
import DynamicTextArea from "react-textarea-autosize" import DynamicTextArea from "react-textarea-autosize"
import { vscode } from "../utilities/vscode" import { vscode } from "../utilities/vscode"
import { ClaudeAskResponse } from "@shared/WebviewMessage" import { ClaudeAskResponse } from "@shared/WebviewMessage"
import ChatRow from "./ChatRow"
interface ChatViewProps { interface ChatViewProps {
messages: ClaudeMessage[] messages: ClaudeMessage[]
} }
// maybe instead of storing state in App, just make chatview always show so dont conditionally load/unload? need to make sure messages are persisted (i remember seeing something about how webviews can be frozen in docs) // maybe instead of storing state in App, just make chatview always show so dont conditionally load/unload? need to make sure messages are persisted (i remember seeing something about how webviews can be frozen in docs)
const ChatView = ({ messages}: ChatViewProps) => { const ChatView = ({}: ChatViewProps) => {
// dummy data for messages
const generateRandomTimestamp = (baseDate: Date, rangeInDays: number): number => {
const rangeInMs = rangeInDays * 24 * 60 * 60 * 1000 // convert days to milliseconds
const randomOffset = Math.floor(Math.random() * rangeInMs * 2) - rangeInMs // rangeInMs * 2 to have offset in both directions
return baseDate.getTime() + randomOffset
}
const baseDate = new Date("2024-07-08T00:00:00Z")
const messages: ClaudeMessage[] = [
{ type: "say", say: "task", text: "type: say, say: task", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "ask",
ask: "request_limit_reached",
text: "type: ask, ask: request_limit_reached",
ts: generateRandomTimestamp(baseDate, 1),
},
{ type: "ask", ask: "followup", text: "type: ask, ask: followup", ts: generateRandomTimestamp(baseDate, 1) },
{ type: "ask", ask: "command", text: "type: ask, ask: command", ts: generateRandomTimestamp(baseDate, 1) },
{ type: "say", say: "error", text: "type: say, say: error", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "say",
say: "api_req_started",
text: "type: say, say: api_req_started",
ts: generateRandomTimestamp(baseDate, 1),
},
{
type: "say",
say: "api_req_finished",
text: "type: say, say: api_req_finished",
ts: generateRandomTimestamp(baseDate, 1),
},
{ type: "say", say: "text", text: "type: say, say: text", ts: generateRandomTimestamp(baseDate, 1) },
{ type: "say", say: "tool", text: "type: say, say: tool", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "say",
say: "command_output",
text: "type: say, say: command_output",
ts: generateRandomTimestamp(baseDate, 1),
},
{
type: "ask",
ask: "completion_result",
text: "type: ask, ask: completion_result",
ts: generateRandomTimestamp(baseDate, 1),
},
]
const [inputValue, setInputValue] = useState("") const [inputValue, setInputValue] = useState("")
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<HTMLTextAreaElement>(null) const textAreaRef = useRef<HTMLTextAreaElement>(null)
@@ -20,7 +68,7 @@ const ChatView = ({ messages}: ChatViewProps) => {
const scrollToBottom = () => { const scrollToBottom = () => {
// https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: 'nearest', inline: 'start' }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" })
} }
useEffect(() => { useEffect(() => {
@@ -42,14 +90,12 @@ const ChatView = ({ messages}: ChatViewProps) => {
} }
}, [messages]) }, [messages])
const handleSendMessage = () => { const handleSendMessage = () => {
const text = inputValue.trim() const text = inputValue.trim()
if (text) { if (text) {
setInputValue("") setInputValue("")
if (messages.length === 0) { if (messages.length === 0) {
vscode.postMessage({ type: "newTask", text })
vscode.postMessage({ type: "newTask", text })
} else if (claudeAsk) { } else if (claudeAsk) {
switch (claudeAsk) { switch (claudeAsk) {
case "followup": case "followup":
@@ -81,28 +127,23 @@ const ChatView = ({ messages}: ChatViewProps) => {
if (textAreaRef.current && !textAreaHeight) { if (textAreaRef.current && !textAreaHeight) {
setTextAreaHeight(textAreaRef.current.offsetHeight) setTextAreaHeight(textAreaRef.current.offsetHeight)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}> <div
style={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
backgroundColor: "gray",
}}>
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}> <div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
{messages.map((message, index) => ( {messages.map((message) => (
<div <ChatRow message={message} />
key={index}
style={{
marginBottom: "10px",
padding: "8px",
borderRadius: "4px",
backgroundColor:
message.type === "ask"
? "var(--vscode-editor-background)"
: "var(--vscode-sideBar-background)",
}}>
<span style={{ whiteSpace: "pre-line", overflowWrap: "break-word" }}>{message.text}</span>
</div>
))} ))}
<div style={{ float:"left", clear: "both" }} ref={messagesEndRef} /> <div style={{ float: "left", clear: "both" }} ref={messagesEndRef} />
</div> </div>
<div style={{ position: "relative", paddingTop: "16px", paddingBottom: "16px" }}> <div style={{ position: "relative", paddingTop: "16px", paddingBottom: "16px" }}>
<DynamicTextArea <DynamicTextArea