Use vscode scrollbar style; add TaskHeader

This commit is contained in:
Saoud Rizwan
2024-07-09 08:57:39 -04:00
parent 391614968c
commit a360d1007e
7 changed files with 294 additions and 103 deletions

View File

@@ -19,12 +19,14 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"react-text-truncate": "^0.19.0",
"react-textarea-autosize": "^8.5.3",
"rewire": "^7.0.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/react-text-truncate": "^0.14.4",
"@types/vscode-webview": "^1.57.5"
}
},
@@ -4562,6 +4564,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-text-truncate": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/@types/react-text-truncate/-/react-text-truncate-0.14.4.tgz",
"integrity": "sha512-qdw8522RqdYkTX0FShDPDx8hIRVjPydW8PXl/wKpPGpAtjJTsaNiFOe0fxMRLXIEQaAZvC5VLlKGGONAetb6nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -16143,6 +16155,19 @@
}
}
},
"node_modules/react-text-truncate": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/react-text-truncate/-/react-text-truncate-0.19.0.tgz",
"integrity": "sha512-QxHpZABfGG0Z3WEYbRTZ+rXdZn50Zvp+sWZXgVAd7FCKAMzv/kcwctTpNmWgXDTpAoHhMjOVwmgRtX3x5yeF4w==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.5.7"
},
"peerDependencies": {
"react": "^15.4.1 || ^16.0.0 || ^17.0.0 || || ^18.0.0",
"react-dom": "^15.4.1 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz",

View File

@@ -14,6 +14,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"react-text-truncate": "^0.19.0",
"react-textarea-autosize": "^8.5.3",
"rewire": "^7.0.0",
"typescript": "^4.9.5",
@@ -44,6 +45,7 @@
]
},
"devDependencies": {
"@types/react-text-truncate": "^0.14.4",
"@types/vscode-webview": "^1.57.5"
}
}

View File

@@ -53,6 +53,91 @@ const App: React.FC = () => {
})
}, [])
// 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: "Starting task, this is my requeirements",
ts: generateRandomTimestamp(baseDate, 1),
},
{
type: "ask",
ask: "request_limit_reached",
text: "Request limit reached",
ts: generateRandomTimestamp(baseDate, 2),
},
{ type: "ask", ask: "followup", text: "Any additional questions?", ts: generateRandomTimestamp(baseDate, 3) },
{ type: "say", say: "error", text: "An error occurred", ts: generateRandomTimestamp(baseDate, 4) },
{ type: "say", say: "text", text: "Some general text", ts: generateRandomTimestamp(baseDate, 7) },
{ type: "say", say: "tool", text: "Using a tool", ts: generateRandomTimestamp(baseDate, 8) },
// First command sequence
{ type: "ask", ask: "command", text: "ls -l", ts: generateRandomTimestamp(baseDate, 9) },
{ type: "say", say: "command_output", text: "file1.txt", ts: generateRandomTimestamp(baseDate, 10) },
{
type: "say",
say: "api_req_started",
text: JSON.stringify({ request: "GET /api/data" }),
ts: generateRandomTimestamp(baseDate, 5),
},
{ type: "say", say: "command_output", text: "file2.txt", ts: generateRandomTimestamp(baseDate, 11) },
{ type: "say", say: "command_output", text: "directory1", ts: generateRandomTimestamp(baseDate, 12) },
{ type: "say", say: "text", text: "Interrupting text", ts: generateRandomTimestamp(baseDate, 13) },
{
type: "say",
say: "api_req_finished",
text: JSON.stringify({ cost: "GET /api/data" }),
ts: generateRandomTimestamp(baseDate, 6),
},
// Second command sequence
{ type: "ask", ask: "command", text: "pwd", ts: generateRandomTimestamp(baseDate, 14) },
{ type: "say", say: "command_output", text: "/home/user", ts: generateRandomTimestamp(baseDate, 15) },
{ type: "ask", ask: "completion_result", text: "Task completed", ts: generateRandomTimestamp(baseDate, 16) },
// Third command sequence (no output)
{ type: "ask", ask: "command", text: "echo Hello", ts: generateRandomTimestamp(baseDate, 17) },
// Testing combineApiRequests
{ type: "say", say: "text", text: "Final message", ts: generateRandomTimestamp(baseDate, 18) },
{ type: "ask", ask: "command", text: "ls -l", ts: generateRandomTimestamp(baseDate, 19) },
{ type: "say", say: "command_output", text: "file1.txt", ts: generateRandomTimestamp(baseDate, 20) },
{
type: "say",
say: "api_req_started",
text: JSON.stringify({ request: "GET /api/data" }),
ts: generateRandomTimestamp(baseDate, 23),
},
{ type: "say", say: "command_output", text: "file2.txt", ts: generateRandomTimestamp(baseDate, 24) },
{ type: "say", say: "text", text: "Some random text", ts: generateRandomTimestamp(baseDate, 25) },
{
type: "say",
say: "api_req_finished",
text: JSON.stringify({ cost: 0.005 }),
ts: generateRandomTimestamp(baseDate, 26),
},
{ type: "ask", ask: "command", text: "pwd", ts: generateRandomTimestamp(baseDate, 27) },
{ type: "say", say: "command_output", text: "/home/user", ts: generateRandomTimestamp(baseDate, 28) },
{
type: "say",
say: "api_req_started",
text: JSON.stringify({ request: "POST /api/update" }),
ts: generateRandomTimestamp(baseDate, 29),
},
{ type: "say", say: "text", text: "Final message", ts: generateRandomTimestamp(baseDate, 30) },
]
return (
<>
{showWelcome ? (
@@ -66,7 +151,7 @@ const App: React.FC = () => {
onDone={() => setShowSettings(false)}
/>
) : (
<ChatView messages={claudeMessages} />
<ChatView messages={messages} />
)}
</>
)

View File

@@ -4,11 +4,11 @@ import { VSCodeButton, VSCodeProgressRing, VSCodeTag } from "@vscode/webview-ui-
interface ChatRowProps {
message: ClaudeMessage
cost?: string
}
const ChatRow: React.FC<ChatRowProps> = ({ message, cost }) => {
const ChatRow: React.FC<ChatRowProps> = ({ message }) => {
const [isExpanded, setIsExpanded] = useState(false)
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined
const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, string | null] => {
switch (type) {
@@ -209,11 +209,6 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, cost }) => {
<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" && (

View File

@@ -7,82 +7,15 @@ import { ClaudeAskResponse } from "@shared/WebviewMessage"
import ChatRow from "./ChatRow"
import { combineCommandSequences } from "../utilities/combineCommandSequences"
import { combineApiRequests } from "../utilities/combineApiRequests"
import TaskHeader from "./TaskHeader"
interface ChatViewProps {
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)
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: "Starting task", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "ask",
ask: "request_limit_reached",
text: "Request limit reached",
ts: generateRandomTimestamp(baseDate, 2),
},
{ type: "ask", ask: "followup", text: "Any additional questions?", ts: generateRandomTimestamp(baseDate, 3) },
{ type: "say", say: "error", text: "An error occurred", ts: generateRandomTimestamp(baseDate, 4) },
{ type: "say", say: "text", text: "Some general text", ts: generateRandomTimestamp(baseDate, 7) },
{ type: "say", say: "tool", text: "Using a tool", ts: generateRandomTimestamp(baseDate, 8) },
// First command sequence
{ type: "ask", ask: "command", text: "ls -l", ts: generateRandomTimestamp(baseDate, 9) },
{ type: "say", say: "command_output", text: "file1.txt", ts: generateRandomTimestamp(baseDate, 10) },
{ type: "say", say: "api_req_started", text: JSON.stringify({ request: "GET /api/data" }), ts: generateRandomTimestamp(baseDate, 5) },
{ type: "say", say: "command_output", text: "file2.txt", ts: generateRandomTimestamp(baseDate, 11) },
{ type: "say", say: "command_output", text: "directory1", ts: generateRandomTimestamp(baseDate, 12) },
{ type: "say", say: "text", text: "Interrupting text", ts: generateRandomTimestamp(baseDate, 13) },
{ type: "say", say: "api_req_finished", text: JSON.stringify({ cost: "GET /api/data" }), ts: generateRandomTimestamp(baseDate, 6) },
// Second command sequence
{ type: "ask", ask: "command", text: "pwd", ts: generateRandomTimestamp(baseDate, 14) },
{ type: "say", say: "command_output", text: "/home/user", ts: generateRandomTimestamp(baseDate, 15) },
{ type: "ask", ask: "completion_result", text: "Task completed", ts: generateRandomTimestamp(baseDate, 16) },
// Third command sequence (no output)
{ type: "ask", ask: "command", text: "echo Hello", ts: generateRandomTimestamp(baseDate, 17) },
// Testing combineApiRequests
{ type: "say", say: "text", text: "Final message", ts: generateRandomTimestamp(baseDate, 18) },
{ type: "ask", ask: "command", text: "ls -l", ts: generateRandomTimestamp(baseDate, 19) },
{ type: "say", say: "command_output", text: "file1.txt", ts: generateRandomTimestamp(baseDate, 20) },
{
type: "say",
say: "api_req_started",
text: JSON.stringify({ request: "GET /api/data" }),
ts: generateRandomTimestamp(baseDate, 23),
},
{ type: "say", say: "command_output", text: "file2.txt", ts: generateRandomTimestamp(baseDate, 24) },
{ type: "say", say: "text", text: "Some random text", ts: generateRandomTimestamp(baseDate, 25) },
{
type: "say",
say: "api_req_finished",
text: JSON.stringify({ cost: 0.005 }),
ts: generateRandomTimestamp(baseDate, 26),
},
{ type: "ask", ask: "command", text: "pwd", ts: generateRandomTimestamp(baseDate, 27) },
{ type: "say", say: "command_output", text: "/home/user", ts: generateRandomTimestamp(baseDate, 28) },
{
type: "say",
say: "api_req_started",
text: JSON.stringify({ request: "POST /api/update" }),
ts: generateRandomTimestamp(baseDate, 29),
},
{ type: "say", say: "text", text: "Final message", ts: generateRandomTimestamp(baseDate, 30) },
]
const ChatView = ({ messages }: ChatViewProps) => {
const task = messages.shift()
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages)), [messages])
const [inputValue, setInputValue] = useState("")
@@ -93,9 +26,9 @@ const ChatView = ({}: ChatViewProps) => {
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
const scrollToBottom = () => {
const scrollToBottom = (instant: boolean = false) => {
// https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" })
(messagesEndRef.current as any)?.scrollIntoView({ behavior: instant ? "instant" : "smooth", block: "nearest", inline: "start" })
}
useEffect(() => {
@@ -160,52 +93,62 @@ const ChatView = ({}: ChatViewProps) => {
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
backgroundColor: "gray",
}}>
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
{modifiedMessages.map((message) => (
<ChatRow message={message} />
<TaskHeader
taskText="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
tokensIn={1000}
tokensOut={1500}
totalCost={0.0025}
/>
<div
className="scrollable"
style={{
flexGrow: 1,
overflowY: "auto",
}}>
{modifiedMessages.map((message, index) => (
<ChatRow key={index} message={message} />
))}
<div style={{ float: "left", clear: "both" }} ref={messagesEndRef} />
</div>
<div style={{ position: "relative", paddingTop: "16px", paddingBottom: "16px" }}>
<div style={{ padding: "16px" }}>
<DynamicTextArea
ref={textAreaRef}
value={inputValue}
disabled={textAreaDisabled}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onHeightChange={() => scrollToBottom()}
onHeightChange={() => scrollToBottom(true)}
placeholder="Type a message..."
maxRows={10}
style={{
width: "100%",
boxSizing: "border-box",
backgroundColor: "var(--vscode-input-background, #3c3c3c)",
color: "var(--vscode-input-foreground, #cccccc)",
border: "1px solid var(--vscode-input-border, #3c3c3c)",
backgroundColor: "var(--vscode-input-background)",
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
fontFamily:
"var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif)",
fontSize: "var(--vscode-editor-font-size, 13px)",
lineHeight: "var(--vscode-editor-line-height, 1.5)",
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
resize: "none",
overflow: "hidden",
paddingTop: "8px",
paddingBottom: "8px",
paddingLeft: "8px",
paddingRight: "40px", // Make room for button
padding: "8px 40px 8px 8px",
}}
/>
{textAreaHeight && (
<div
style={{
position: "absolute",
right: "12px",
right: "18px",
height: `${textAreaHeight}px`,
bottom: "18px",
display: "flex",

View File

@@ -0,0 +1,84 @@
import React, { useState } from "react"
import TextTruncate from "react-text-truncate"
interface TaskHeaderProps {
taskText: string
tokensIn: number
tokensOut: number
totalCost: number
}
const TaskHeader: React.FC<TaskHeaderProps> = ({ taskText, tokensIn, tokensOut, totalCost }) => {
const [isExpanded, setIsExpanded] = useState(false)
const toggleExpand = () => setIsExpanded(!isExpanded)
return (
<div
style={{
padding: "10px",
}}>
<div
style={{
backgroundColor: "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
borderRadius: "3px",
padding: "8px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}>
<div style={{ fontSize: "var(--vscode-font-size)", lineHeight: "1.5" }}>
<TextTruncate
line={isExpanded ? 0 : 3}
element="span"
truncateText="…"
text={taskText}
textTruncateChild={
<span
style={{
cursor: "pointer",
color: "var(--vscode-textLink-foreground)",
marginLeft: "5px",
}}
onClick={toggleExpand}>
See more
</span>
}
/>
{isExpanded && (
<span
style={{
cursor: "pointer",
color: "var(--vscode-textLink-foreground)",
marginLeft: "5px",
}}
onClick={toggleExpand}>
See less
</span>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: "bold" }}>Tokens:</span>
<div style={{ display: "flex", gap: "8px" }}>
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<i className="codicon codicon-arrow-down" style={{ fontSize: "12px", marginBottom: "-1px" }} />
{tokensIn.toLocaleString()}
</span>
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<i className="codicon codicon-arrow-up" style={{ fontSize: "12px", marginBottom: "-1px" }} />
{tokensOut.toLocaleString()}
</span>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: "bold" }}>API Cost:</span>
<span>${totalCost.toFixed(4)}</span>
</div>
</div>
</div>
</div>
)
}
export default TaskHeader

View File

@@ -9,9 +9,7 @@
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
} */
body {
margin: 0;
}
textarea:focus {
outline: 1.5px solid var(--vscode-focusBorder, #007fd4);
}
@@ -19,3 +17,62 @@ textarea:focus {
vscode-button::part(control):focus {
outline: none;
}
/*
Use vscode native scrollbar styles
https://github.com/gitkraken/vscode-gitlens/blob/b1d71d4844523e8b2ef16f9e007068e91f46fd88/src/webviews/apps/home/home.scss
*/
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
}
body.scrollable,
.scrollable {
border-color: transparent;
transition: border-color 0.7s linear;
}
body:hover.scrollable,
body:hover .scrollable,
body:focus-within.scrollable,
body:focus-within .scrollable {
border-color: var(--vscode-scrollbarSlider-background);
transition: none;
}
::-webkit-scrollbar-corner {
background-color: transparent !important;
}
::-webkit-scrollbar-thumb {
background-color: transparent;
border-color: inherit;
border-right-style: inset;
border-right-width: calc(100vw + 100vh);
border-radius: unset !important;
}
::-webkit-scrollbar-thumb:hover {
border-color: var(--vscode-scrollbarSlider-hoverBackground);
}
::-webkit-scrollbar-thumb:active {
border-color: var(--vscode-scrollbarSlider-activeBackground);
}
/*
Fix VSCode ignoring webkit scrollbar modifiers
https://github.com/microsoft/vscode/issues/213045
*/
@supports selector(::-webkit-scrollbar) {
html {
scrollbar-color: unset;
}
}