Files
Roo-Code/webview-ui/src/components/chat/BrowserSessionRow.tsx
2024-10-27 14:52:54 -04:00

512 lines
15 KiB
TypeScript

import deepEqual from "fast-deep-equal"
import React, { memo, useEffect, useMemo, useRef, useState } from "react"
import { useSize } from "react-use"
import {
BrowserAction,
BrowserActionResult,
ClineMessage,
ClineSayBrowserAction,
} from "../../../../src/shared/ExtensionMessage"
import { vscode } from "../../utils/vscode"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { ChatRowContent, ProgressIndicator } from "./ChatRow"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
interface BrowserSessionRowProps {
messages: ClineMessage[]
isExpanded: (messageTs: number) => boolean
onToggleExpand: (messageTs: number) => void
lastModifiedMessage?: ClineMessage
isLast: boolean
onHeightChange: (isTaller: boolean) => void
}
/*
- console logs will be aggregate up to that current page
- while isbrowsing, disable next prev buttons and latest action if click use that as position instead of display state
- action rows ui
- pagination make look better
- test cancel/abort session
- test resume task
*/
const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const { messages, isLast, onHeightChange, lastModifiedMessage } = props
const prevHeightRef = useRef(0)
const [maxActionHeight, setMaxActionHeight] = useState(0)
const [consoleLogsExpanded, setConsoleLogsExpanded] = useState(false)
const isBrowsing = useMemo(() => {
return (
isLast && lastModifiedMessage?.ask !== "resume_task" && lastModifiedMessage?.ask !== "resume_completed_task"
)
}, [isLast, lastModifiedMessage])
// Organize messages into pages with current state and next action
const pages = useMemo(() => {
const result: {
currentState: {
url?: string
screenshot?: string
mousePosition?: string
consoleLogs?: string
messages: ClineMessage[] // messages up to and including the result
}
nextAction?: {
messages: ClineMessage[] // messages leading to next result
}
}[] = []
let currentStateMessages: ClineMessage[] = []
let nextActionMessages: ClineMessage[] = []
messages.forEach((message) => {
if (message.ask === "browser_action_launch") {
// Start first page
currentStateMessages = [message]
} else if (message.say === "browser_action_result") {
// Complete current state
currentStateMessages.push(message)
const resultData = JSON.parse(message.text || "{}") as BrowserActionResult
// Add page with current state and previous next actions
result.push({
currentState: {
url: resultData.currentUrl,
screenshot: resultData.screenshot,
mousePosition: resultData.currentMousePosition,
consoleLogs: resultData.logs,
messages: [...currentStateMessages],
},
nextAction:
nextActionMessages.length > 0
? {
messages: [...nextActionMessages],
}
: undefined,
})
// Reset for next page
currentStateMessages = []
nextActionMessages = []
} else if (
message.say === "api_req_started" ||
message.say === "text" ||
message.say === "browser_action"
) {
// These messages lead to the next result, so they should always go in nextActionMessages
nextActionMessages.push(message)
} else {
// Any other message types
currentStateMessages.push(message)
}
})
// Add incomplete page if exists
if (currentStateMessages.length > 0 || nextActionMessages.length > 0) {
result.push({
currentState: {
messages: [...currentStateMessages],
},
nextAction:
nextActionMessages.length > 0
? {
messages: [...nextActionMessages],
}
: undefined,
})
}
return result
}, [messages])
// Auto-advance to latest page
const [currentPageIndex, setCurrentPageIndex] = useState(0)
useEffect(() => {
setCurrentPageIndex(pages.length - 1)
}, [pages.length])
// Get initial URL from launch message
const initialUrl = useMemo(() => {
const launchMessage = messages.find((m) => m.ask === "browser_action_launch")
return launchMessage?.text || ""
}, [messages])
// Find the latest available URL and screenshot
const latestState = useMemo(() => {
for (let i = pages.length - 1; i >= 0; i--) {
const page = pages[i]
if (page.currentState.url || page.currentState.screenshot) {
return {
url: page.currentState.url,
mousePosition: page.currentState.mousePosition,
consoleLogs: page.currentState.consoleLogs,
screenshot: page.currentState.screenshot,
}
}
}
return { url: undefined, mousePosition: undefined, consoleLogs: undefined, screenshot: undefined }
}, [pages])
const currentPage = pages[currentPageIndex]
const isLastPage = currentPageIndex === pages.length - 1
// Use latest state if we're on the last page and don't have a state yet
const displayState = isLastPage
? {
url: currentPage?.currentState.url || latestState.url || initialUrl,
mousePosition: currentPage?.currentState.mousePosition || latestState.mousePosition || "400,300",
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot || latestState.screenshot,
}
: {
url: currentPage?.currentState.url || initialUrl,
mousePosition: currentPage?.currentState.mousePosition || "400,300",
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot,
}
const [actionContent, { height: actionHeight }] = useSize(
<div>
{currentPage?.nextAction?.messages.map((message) => (
<BrowserSessionRowContent
key={message.ts}
{...props}
message={message}
setMaxActionHeight={setMaxActionHeight}
/>
))}
</div>
)
useEffect(() => {
if (actionHeight === 0 || actionHeight === Infinity) {
return
}
if (actionHeight > maxActionHeight) {
setMaxActionHeight(actionHeight)
}
}, [actionHeight, maxActionHeight])
useEffect(() => {
if (!displayState.consoleLogs || displayState.consoleLogs.trim() === "") {
setConsoleLogsExpanded(false)
}
}, [displayState.consoleLogs])
const [browserSessionRow, { height }] = useSize(
<div style={{ padding: "10px 6px 10px 15px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px" }}>
{isBrowsing ? (
<ProgressIndicator />
) : (
<span
className={`codicon codicon-inspect`}
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
)}
<span style={{ fontWeight: "bold" }}>
<>Cline wants to use the browser:</>
</span>
</div>
<div
style={{
borderRadius: 3,
border: "1px solid var(--vscode-editorGroup-border)",
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
{/* URL Bar */}
<div
style={{
margin: "5px auto",
width: "calc(100% - 10px)",
boxSizing: "border-box", // includes padding in width calculation
backgroundColor: "var(--vscode-input-background)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "4px",
padding: "3px 5px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--vscode-input-foreground)",
fontSize: "12px",
wordBreak: "break-all",
whiteSpace: "normal",
}}>
{displayState.url}
</div>
{/* Screenshot Area */}
<div
style={{
width: "100%",
paddingBottom: "75%",
position: "relative",
backgroundColor: "var(--vscode-input-background)",
}}>
{displayState.screenshot ? (
<img
src={displayState.screenshot}
alt="Browser screenshot"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "contain",
}}
onClick={() =>
vscode.postMessage({
type: "openImage",
text: displayState.screenshot,
})
}
/>
) : (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}>
<span
className="codicon codicon-globe"
style={{ fontSize: "80px", color: "var(--vscode-descriptionForeground)" }}
/>
</div>
)}
{displayState.mousePosition && (
<BrowserCursor
style={{
position: "absolute",
top: `${(parseInt(displayState.mousePosition.split(",")[1]) / 600) * 100}%`,
left: `${(parseInt(displayState.mousePosition.split(",")[0]) / 800) * 100}%`,
transition: "top 0.3s ease-out, left 0.3s ease-out",
}}
/>
)}
</div>
<div style={{ width: "100%" }}>
<div
onClick={() => {
setConsoleLogsExpanded(!consoleLogsExpanded)
}}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
width: "100%",
justifyContent: "flex-start",
cursor: "pointer",
padding: `9px 8px ${consoleLogsExpanded ? 0 : 8}px 8px`,
}}>
<span className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`}></span>
<span style={{ fontSize: "0.8em" }}>Console Logs</span>
</div>
{consoleLogsExpanded && (
<CodeBlock source={`${"```"}shell\n${displayState.consoleLogs || "(No new logs)"}\n${"```"}`} />
)}
</div>
</div>
{/* Action content with min height */}
<div style={{ minHeight: maxActionHeight }}>{actionContent}</div>
{/* Pagination moved to bottom */}
{pages.length > 1 && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
marginTop: "15px",
borderTop: "1px solid var(--vscode-editorGroup-border)",
}}>
<div>
Step {currentPageIndex + 1} of {pages.length}
</div>
<div style={{ display: "flex", gap: "4px" }}>
<VSCodeButton
disabled={currentPageIndex === 0 || isBrowsing}
onClick={() => setCurrentPageIndex((i) => i - 1)}>
Previous
</VSCodeButton>
<VSCodeButton
disabled={currentPageIndex === pages.length - 1 || isBrowsing}
onClick={() => setCurrentPageIndex((i) => i + 1)}>
Next
</VSCodeButton>
</div>
</div>
)}
</div>
)
// Height change effect
useEffect(() => {
const isInitialRender = prevHeightRef.current === 0
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
if (!isInitialRender) {
onHeightChange(height > prevHeightRef.current)
}
prevHeightRef.current = height
}
}, [height, isLast, onHeightChange])
return browserSessionRow
}, deepEqual)
interface BrowserSessionRowContentProps extends Omit<BrowserSessionRowProps, "messages"> {
message: ClineMessage
setMaxActionHeight: (height: number) => void
}
const BrowserSessionRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isLast,
setMaxActionHeight,
}: BrowserSessionRowContentProps) => {
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
}
// Copy all the rendering logic from ChatRowContent
// This includes handling all message types: api_req_started, browser_action, text, etc.
// The implementation would be identical to ChatRowContent
const getBrowserActionText = (action: BrowserAction, coordinate?: string, text?: string) => {
switch (action) {
case "click":
return `Click (${coordinate?.replace(",", ", ")})`
case "type":
return `Type "${text}"`
case "scroll_down":
return "Scroll down"
case "scroll_up":
return "Scroll up"
case "close":
return "Close browser"
default:
return action
}
}
switch (message.type) {
case "say":
switch (message.say) {
case "api_req_started":
case "text":
return (
<div style={{ padding: "15px 0 0px 0" }}>
<ChatRowContent
message={message}
isExpanded={isExpanded(message.ts)}
onToggleExpand={() => {
if (message.say === "api_req_started") {
setMaxActionHeight(0)
}
onToggleExpand(message.ts)
}}
lastModifiedMessage={lastModifiedMessage}
isLast={isLast}
/>
</div>
)
case "browser_action":
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
return (
<div style={{ padding: "15px 0 0 0" }}>
<div
style={{
borderRadius: 3,
backgroundColor: CODE_BLOCK_BG_COLOR,
overflow: "hidden",
border: "1px solid var(--vscode-editorGroup-border)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
padding: "9px 10px",
}}>
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
marginRight: "8px",
}}>
<span style={{ fontWeight: 500 }}>Browse Action: </span>
{getBrowserActionText(
browserAction.action,
browserAction.coordinate,
browserAction.text
)}
</span>
</div>
</div>
</div>
)
default:
return null
}
case "ask":
switch (message.ask) {
case "browser_action_launch":
return (
<>
<div style={headerStyle}>
<span style={{ fontWeight: "bold" }}>Browser Session Started</span>
</div>
<div
style={{
borderRadius: 3,
border: "1px solid var(--vscode-editorGroup-border)",
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<CodeBlock source={`${"```"}shell\n${message.text}\n${"```"}`} forceWrap={true} />
</div>
</>
)
default:
return null
}
}
}
const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({ style }) => {
// (can't use svgs in vsc extensions)
const cursorBase64 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAYCAYAAAAVibZIAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFaADAAQAAAABAAAAGAAAAADwi9a/AAADGElEQVQ4EZ2VbUiTURTH772be/PxZdsz3cZwC4RVaB8SAjMpxQwSWZbQG/TFkN7oW1Df+h6IRV9C+hCpKUSIZUXOfGM5tAKViijFFEyfZ7Ol29S1Pbdzl8Uw9+aBu91zzv3/nt17zt2DEZjBYOAkKrtFMXIghAWM8U2vMN/FctsxGRMpM7NbEEYNMM2CYUSInlJx3OpawO9i+XSNQYkmk2uFb9njzkcfVSr1p/GJiQKMULVaw2WuBv296UKRxWJR6wxGCmM1EAhSNppv33GBH9qI32cPTAtss9lUm6EM3N7R+RbigT+5/CeosFCZKpjEW+iorS1pb30wDUXzQfHqtD/9L3ieZ2ee1OJCmbL8QHnRs+4uj0wmW4QzrpCwvJ8zGg3JqAmhTLynuLiwv8/5KyND8Q3cEkUEDWu15oJE4KRQJt5hs1rcriGNRqP+DK4dyyWXXm/aFQ+cEpSJ8/LyDGPuEZNOmzsOroUSOqzXG/dtBU4ZysTZYKNut91sNo2Cq6cE9enz86s2g9OCMrFSqVC5hgb32u072W3jKMU90Hb1seC0oUwsB+t92bO/rKx0EFGkgFCnjjc1/gVvC8rE0L+4o63t4InjxwbAJQjTe3qD8QrLkXA4DC24fWtuajp06cLFYSBIFKGmXKPRRmAnME9sPt+yLwIWb9WN69fKoTneQz4Dh2mpPNkvfeV0jjecb9wNAkwIEVQq5VJOds4Kb+DXoAsiVquVwI1Dougpij6UyGYx+5cKroeDEFibm5lWRRMbH1+npmYrq6qhwlQHIbajZEf1fElcqGGFpGg9HMuKzpfBjhytCTMgkJ56RX09zy/ysENTBElmjIgJnmNChJqohDVQqpEfwkILE8v/o0GAnV9F1eEvofVQCbiTBEXOIPQh5PGgefDZeAcjrpGZjULBr/m3tZOnz7oEQWRAQZLjWlEU/XEJWySiILgRc5Cz1DkcAyuBFcnpfF0JiXWKpcolQXizhS5hKAqFpr0MVbgbuxJ6+5xX+P4wNpbqPPrugZfbmIbLmgQR3Aw8QSi66hUXulOFbF73GxqjE5BNXWNeAAAAAElFTkSuQmCC"
return (
<img
src={cursorBase64}
style={{
width: "17px",
height: "22px",
...style,
}}
alt="cursor"
/>
)
}
export default BrowserSessionRow