Skip to content
Merged

Chat UI #1012

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,868 changes: 2,766 additions & 102 deletions webview-ui/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.3",
"react-remark": "^2.1.0",
"react-textarea-autosize": "^8.5.3",
"react-use": "^17.5.1",
"react-virtuoso": "^4.7.13",
"rehype-highlight": "^7.0.0",
"remark-gfm": "^4.0.1",
"shell-quote": "^1.8.2",
"styled-components": "^6.1.13",
"tailwind-merge": "^2.6.0",
Expand Down Expand Up @@ -75,6 +77,7 @@
"jest": "^27.5.1",
"jest-environment-jsdom": "^27.5.1",
"jest-simple-dot-reporter": "^1.0.5",
"shiki": "^2.3.2",
"storybook": "^8.5.6",
"storybook-dark-mode": "^4.0.2",
"ts-jest": "^27.1.5",
Expand Down
29 changes: 29 additions & 0 deletions webview-ui/src/components/ui/chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { HTMLAttributes } from "react"

import { cn } from "@/lib/utils"

import { ChatHandler } from "./types"
import { ChatProvider } from "./ChatProvider"
import { ChatMessages } from "./ChatMessages"
import { ChatInput } from "./ChatInput"

type ChatProps = HTMLAttributes<HTMLDivElement> & {
assistantName: string
handler: ChatHandler
}

export const Chat = ({ assistantName, handler, ...props }: ChatProps) => (
<ChatProvider value={{ assistantName, ...handler }}>
<InnerChat {...props} />
</ChatProvider>
)

type InnerChatProps = HTMLAttributes<HTMLDivElement>

const InnerChat = ({ className, children, ...props }: InnerChatProps) => (
<div className={cn("relative flex flex-col flex-1 min-h-0", className)} {...props}>
<ChatMessages />
{children}
<ChatInput />
</div>
)
100 changes: 100 additions & 0 deletions webview-ui/src/components/ui/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { PaperPlaneIcon, StopIcon } from "@radix-ui/react-icons"

import { Button, AutosizeTextarea } from "@/components/ui"

import { ChatInputProvider } from "./ChatInputProvider"
import { useChatUI } from "./useChatUI"
import { useChatInput } from "./useChatInput"

export function ChatInput() {
const { input, setInput, append, isLoading } = useChatUI()
const isDisabled = isLoading || !input.trim()

const submit = async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding try/catch error handling for the async 'submit' function to manage possible rejections from append. This ensures errors are handled gracefully.

if (input.trim() === "") {
return
}

setInput("")
await append({ role: "user", content: input })
}

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await submit()
}

const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isDisabled) {
return
}

if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
await submit()
}
}

return (
<ChatInputProvider value={{ isDisabled, handleKeyDown, handleSubmit }}>
<div className="border-t border-vscode-editor-background p-3">
<ChatInputForm />
</div>
</ChatInputProvider>
)
}

function ChatInputForm() {
const { handleSubmit } = useChatInput()

return (
<form onSubmit={handleSubmit} className="relative">
<ChatInputField />
<ChatInputSubmit />
</form>
)
}

interface ChatInputFieldProps {
placeholder?: string
}

function ChatInputField({ placeholder = "Chat" }: ChatInputFieldProps) {
const { input, setInput } = useChatUI()
const { handleKeyDown } = useChatInput()

return (
<AutosizeTextarea
name="input"
placeholder={placeholder}
minHeight={75}
maxHeight={200}
value={input}
onChange={({ target: { value } }) => setInput(value)}
onKeyDown={handleKeyDown}
className="resize-none px-3 pt-3 pb-[50px]"
/>
)
}

function ChatInputSubmit() {
const { isLoading, stop } = useChatUI()
const { isDisabled } = useChatInput()
const isStoppable = isLoading && !!stop

return (
<div className="absolute bottom-[1px] left-[1px] right-[1px] h-[40px] bg-input border-t border-vscode-editor-background rounded-b-md p-1">
<div className="flex flex-row-reverse items-center gap-2">
{isStoppable ? (
<Button type="button" variant="ghost" size="sm" onClick={stop}>
<StopIcon className="text-destructive" />
</Button>
) : (
<Button type="submit" variant="ghost" size="icon" disabled={isDisabled}>
<PaperPlaneIcon />
</Button>
)}
</div>
</div>
)
}
11 changes: 11 additions & 0 deletions webview-ui/src/components/ui/chat/ChatInputProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext } from "react"

interface ChatInputContext {
isDisabled: boolean
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
}

export const chatInputContext = createContext<ChatInputContext | null>(null)

export const ChatInputProvider = chatInputContext.Provider
105 changes: 105 additions & 0 deletions webview-ui/src/components/ui/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useMemo } from "react"
import { CopyIcon, CheckIcon } from "@radix-ui/react-icons"
import { BrainCircuit, CircleUserRound } from "lucide-react"

import { cn } from "@/lib/utils"
import { useClipboard } from "@/components/ui/hooks"
import { Badge } from "@/components/ui"
import { Markdown } from "@/components/ui/markdown"

import { BadgeData, ChatHandler, Message, MessageAnnotationType } from "./types"
import { ChatMessageProvider } from "./ChatMessageProvider"
import { useChatUI } from "./useChatUI"
import { useChatMessage } from "./useChatMessage"

interface ChatMessageProps {
message: Message
isLast: boolean
isHeaderVisible: boolean
isLoading?: boolean
append?: ChatHandler["append"]
}

export function ChatMessage({ message, isLast, isHeaderVisible, isLoading, append }: ChatMessageProps) {
const badges = useMemo(
() =>
message.annotations
?.filter(({ type }) => type === MessageAnnotationType.BADGE)
.map(({ data }) => data as BadgeData),
[message.annotations],
)

return (
<ChatMessageProvider value={{ message, isLast }}>
<div
className={cn("relative group flex flex-col text-secondary-foreground", {
"bg-vscode-input-background/50": message.role === "user",
})}>
{isHeaderVisible && <ChatMessageHeader badges={badges} />}
<ChatMessageContent isHeaderVisible={isHeaderVisible} />
<ChatMessageActions />
</div>
</ChatMessageProvider>
)
}

interface ChatMessageHeaderProps {
badges?: BadgeData[]
}

function ChatMessageHeader({ badges }: ChatMessageHeaderProps) {
return (
<div className="flex flex-row items-center justify-between border-t border-accent px-3 pt-3 pb-1">
<ChatMessageAvatar />
{badges?.map(({ label, variant = "outline" }) => (
<Badge variant={variant} key={label}>
{label}
</Badge>
))}
</div>
)
}

const icons: Record<string, React.ReactNode> = {
user: <CircleUserRound className="h-4 w-4" />,
assistant: <BrainCircuit className="h-4 w-4" />,
}

function ChatMessageAvatar() {
const { assistantName } = useChatUI()
const { message } = useChatMessage()

return icons[message.role] ? (
<div className="flex flex-row items-center gap-1">
<div className="opacity-25 select-none">{icons[message.role]}</div>
<div className="text-muted">{message.role === "user" ? "You" : assistantName}</div>
</div>
) : null
}

interface ChatMessageContentProps {
isHeaderVisible: boolean
}

function ChatMessageContent({ isHeaderVisible }: ChatMessageContentProps) {
const { message } = useChatMessage()

return (
<div className={cn("flex flex-col gap-4 flex-1 min-w-0 px-4 pb-6", { "pt-4": isHeaderVisible })}>
<Markdown content={message.content} />
</div>
)
}

function ChatMessageActions() {
const { message } = useChatMessage()
const { isCopied, copy } = useClipboard()

return (
<div
className="absolute right-2 bottom-2 opacity-0 group-hover:opacity-25 cursor-pointer"
onClick={() => copy(message.content)}>
{isCopied ? <CheckIcon /> : <CopyIcon />}
</div>
)
}
12 changes: 12 additions & 0 deletions webview-ui/src/components/ui/chat/ChatMessageProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext } from "react"

import { Message } from "./types"

export interface ChatMessageContext {
message: Message
isLast: boolean
}

export const chatMessageContext = createContext<ChatMessageContext | null>(null)

export const ChatMessageProvider = chatMessageContext.Provider
41 changes: 41 additions & 0 deletions webview-ui/src/components/ui/chat/ChatMessages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react"
import { Virtuoso, VirtuosoHandle } from "react-virtuoso"

import { useChatUI } from "./useChatUI"
import { ChatMessage } from "./ChatMessage"

export function ChatMessages() {
const { messages, isLoading, append } = useChatUI()
const messageCount = messages.length
const virtuoso = useRef<VirtuosoHandle>(null)

useEffect(() => {
if (!virtuoso.current) {
return
}

requestAnimationFrame(() =>
virtuoso.current?.scrollToIndex({ index: messageCount - 1, align: "end", behavior: "smooth" }),
)
}, [messageCount])

return (
<Virtuoso
ref={virtuoso}
data={messages}
totalCount={messageCount}
itemContent={(index, message) => (
<ChatMessage
key={index}
message={message}
isHeaderVisible={
!!message.annotations?.length || index === 0 || messages[index - 1].role !== message.role
}
isLast={index === messageCount - 1}
isLoading={isLoading}
append={append}
/>
)}
/>
)
}
11 changes: 11 additions & 0 deletions webview-ui/src/components/ui/chat/ChatProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext } from "react"

import { ChatHandler } from "./types"

type ChatContext = ChatHandler & {
assistantName: string
}

export const chatContext = createContext<ChatContext | null>(null)

export const ChatProvider = chatContext.Provider
2 changes: 2 additions & 0 deletions webview-ui/src/components/ui/chat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types"
export * from "./Chat"
39 changes: 39 additions & 0 deletions webview-ui/src/components/ui/chat/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface Message {
role: "system" | "user" | "assistant" | "data"
content: string
annotations?: MessageAnnotation[]
}

export type ChatHandler = {
isLoading: boolean
setIsLoading: (isLoading: boolean, message?: string) => void

loadingMessage?: string
setLoadingMessage?: (message: string) => void

input: string
setInput: (input: string) => void

messages: Message[]

reload?: (options?: { data?: any }) => void
stop?: () => void
append: (message: Message, options?: { data?: any }) => Promise<string | null | undefined>
reset?: () => void
}

export enum MessageAnnotationType {
BADGE = "badge",
}

export type BadgeData = {
label: string
variant?: "default" | "secondary" | "destructive" | "outline"
}

export type AnnotationData = BadgeData

export type MessageAnnotation = {
type: MessageAnnotationType
data: AnnotationData
}
13 changes: 13 additions & 0 deletions webview-ui/src/components/ui/chat/useChatInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useContext } from "react"

import { chatInputContext } from "./ChatInputProvider"

export const useChatInput = () => {
const context = useContext(chatInputContext)

if (!context) {
throw new Error("useChatInput must be used within a ChatInputProvider")
}

return context
}
Loading
Loading