Skip to content

Commit 45bf2a0

Browse files
committed
Composable, reusable chat interface with Markdown support + VSCode theme syntax highlighting
1 parent d056bb8 commit 45bf2a0

24 files changed

+2388
-69
lines changed

webview-ui/package-lock.json

Lines changed: 1717 additions & 69 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"lucide-react": "^0.475.0",
3737
"react": "^18.3.1",
3838
"react-dom": "^18.3.1",
39+
"react-markdown": "^9.0.3",
3940
"react-remark": "^2.1.0",
4041
"react-textarea-autosize": "^8.5.3",
4142
"react-use": "^17.5.1",
@@ -75,6 +76,7 @@
7576
"jest": "^27.5.1",
7677
"jest-environment-jsdom": "^27.5.1",
7778
"jest-simple-dot-reporter": "^1.0.5",
79+
"shiki": "^2.3.2",
7880
"storybook": "^8.5.6",
7981
"storybook-dark-mode": "^4.0.2",
8082
"ts-jest": "^27.1.5",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { HTMLAttributes } from "react"
2+
3+
import { cn } from "@/lib/utils"
4+
5+
import { ChatHandler } from "./types"
6+
import { ChatProvider } from "./ChatProvider"
7+
import { ChatMessages } from "./ChatMessages"
8+
import { ChatInput } from "./ChatInput"
9+
10+
type ChatProps = HTMLAttributes<HTMLDivElement> & {
11+
assistantName: string
12+
handler: ChatHandler
13+
}
14+
15+
export const Chat = ({ assistantName, handler, ...props }: ChatProps) => (
16+
<ChatProvider value={{ assistantName, ...handler }}>
17+
<InnerChat {...props} />
18+
</ChatProvider>
19+
)
20+
21+
type InnerChatProps = HTMLAttributes<HTMLDivElement>
22+
23+
const InnerChat = ({ className, children, ...props }: InnerChatProps) => (
24+
<div className={cn("relative flex flex-col flex-1 min-h-0", className)} {...props}>
25+
<ChatMessages />
26+
{children}
27+
<ChatInput />
28+
</div>
29+
)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { PaperPlaneIcon, StopIcon } from "@radix-ui/react-icons"
2+
3+
import { Button, AutosizeTextarea } from "@/components/ui"
4+
5+
import { ChatInputProvider } from "./ChatInputProvider"
6+
import { useChatUI } from "./useChatUI"
7+
import { useChatInput } from "./useChatInput"
8+
9+
export function ChatInput() {
10+
const { input, setInput, append, isLoading } = useChatUI()
11+
const isDisabled = isLoading || !input.trim()
12+
13+
const submit = async () => {
14+
if (input.trim() === "") {
15+
return
16+
}
17+
18+
setInput("")
19+
await append({ role: "user", content: input })
20+
}
21+
22+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
23+
e.preventDefault()
24+
await submit()
25+
}
26+
27+
const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
28+
if (isDisabled) {
29+
return
30+
}
31+
32+
if (e.key === "Enter" && !e.shiftKey) {
33+
e.preventDefault()
34+
await submit()
35+
}
36+
}
37+
38+
return (
39+
<ChatInputProvider value={{ isDisabled, handleKeyDown, handleSubmit }}>
40+
<div className="border-t border-vscode-editor-background p-3">
41+
<ChatInputForm />
42+
</div>
43+
</ChatInputProvider>
44+
)
45+
}
46+
47+
function ChatInputForm() {
48+
const { handleSubmit } = useChatInput()
49+
50+
return (
51+
<form onSubmit={handleSubmit} className="relative">
52+
<ChatInputField />
53+
<ChatInputSubmit />
54+
</form>
55+
)
56+
}
57+
58+
interface ChatInputFieldProps {
59+
placeholder?: string
60+
}
61+
62+
function ChatInputField({ placeholder = "Chat" }: ChatInputFieldProps) {
63+
const { input, setInput } = useChatUI()
64+
const { handleKeyDown } = useChatInput()
65+
66+
return (
67+
<AutosizeTextarea
68+
name="input"
69+
placeholder={placeholder}
70+
minHeight={75}
71+
maxHeight={200}
72+
value={input}
73+
onChange={({ target: { value } }) => setInput(value)}
74+
onKeyDown={handleKeyDown}
75+
className="resize-none px-3 pt-3 pb-[50px]"
76+
/>
77+
)
78+
}
79+
80+
function ChatInputSubmit() {
81+
const { isLoading, stop } = useChatUI()
82+
const { isDisabled } = useChatInput()
83+
const isStoppable = isLoading && !!stop
84+
85+
return (
86+
<div className="absolute bottom-[1px] left-[1px] right-[1px] h-[40px] bg-input border-t border-vscode-editor-background rounded-b-md p-1">
87+
<div className="flex flex-row-reverse items-center gap-2">
88+
{isStoppable ? (
89+
<Button type="button" variant="ghost" size="sm" onClick={stop}>
90+
<StopIcon className="text-destructive" />
91+
</Button>
92+
) : (
93+
<Button type="submit" variant="ghost" size="icon" disabled={isDisabled}>
94+
<PaperPlaneIcon />
95+
</Button>
96+
)}
97+
</div>
98+
</div>
99+
)
100+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createContext } from "react"
2+
3+
interface ChatInputContext {
4+
isDisabled: boolean
5+
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
6+
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
7+
}
8+
9+
export const chatInputContext = createContext<ChatInputContext | null>(null)
10+
11+
export const ChatInputProvider = chatInputContext.Provider
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useMemo } from "react"
2+
import { CopyIcon, CheckIcon } from "@radix-ui/react-icons"
3+
import { BrainCircuit, CircleUserRound } from "lucide-react"
4+
5+
import { cn } from "@/lib/utils"
6+
import { useClipboard } from "@/components/ui/hooks"
7+
import { Badge } from "@/components/ui"
8+
import { Markdown } from "@/components/ui/markdown"
9+
10+
import { BadgeData, ChatHandler, Message, MessageAnnotationType } from "./types"
11+
import { ChatMessageProvider } from "./ChatMessageProvider"
12+
import { useChatUI } from "./useChatUI"
13+
import { useChatMessage } from "./useChatMessage"
14+
15+
interface ChatMessageProps {
16+
message: Message
17+
isLast: boolean
18+
isHeaderVisible: boolean
19+
isLoading?: boolean
20+
append?: ChatHandler["append"]
21+
}
22+
23+
export function ChatMessage({ message, isLast, isHeaderVisible, isLoading, append }: ChatMessageProps) {
24+
const badges = useMemo(
25+
() =>
26+
message.annotations
27+
?.filter(({ type }) => type === MessageAnnotationType.BADGE)
28+
.map(({ data }) => data as BadgeData),
29+
[message.annotations],
30+
)
31+
32+
return (
33+
<ChatMessageProvider value={{ message, isLast }}>
34+
<div
35+
className={cn("relative group flex flex-col text-secondary-foreground", {
36+
"bg-vscode-input-background/50": message.role === "user",
37+
})}>
38+
{isHeaderVisible && <ChatMessageHeader badges={badges} />}
39+
<ChatMessageContent isHeaderVisible={isHeaderVisible} />
40+
<ChatMessageActions />
41+
</div>
42+
</ChatMessageProvider>
43+
)
44+
}
45+
46+
interface ChatMessageHeaderProps {
47+
badges?: BadgeData[]
48+
}
49+
50+
function ChatMessageHeader({ badges }: ChatMessageHeaderProps) {
51+
return (
52+
<div className="flex flex-row items-center justify-between border-t border-accent px-3 pt-3 pb-1">
53+
<ChatMessageAvatar />
54+
{badges?.map(({ label, variant = "outline" }) => (
55+
<Badge variant={variant} key={label}>
56+
{label}
57+
</Badge>
58+
))}
59+
</div>
60+
)
61+
}
62+
63+
const icons: Record<string, React.ReactNode> = {
64+
user: <CircleUserRound className="h-4 w-4" />,
65+
assistant: <BrainCircuit className="h-4 w-4" />,
66+
}
67+
68+
function ChatMessageAvatar() {
69+
const { assistantName } = useChatUI()
70+
const { message } = useChatMessage()
71+
72+
return icons[message.role] ? (
73+
<div className="flex flex-row items-center gap-1">
74+
<div className="opacity-25 select-none">{icons[message.role]}</div>
75+
<div className="text-muted">{message.role === "user" ? "You" : assistantName}</div>
76+
</div>
77+
) : null
78+
}
79+
80+
interface ChatMessageContentProps {
81+
isHeaderVisible: boolean
82+
}
83+
84+
function ChatMessageContent({ isHeaderVisible }: ChatMessageContentProps) {
85+
const { message } = useChatMessage()
86+
87+
return (
88+
<div className={cn("flex flex-col gap-4 flex-1 min-w-0 px-4 pb-6", { "pt-4": isHeaderVisible })}>
89+
<Markdown content={message.content} />
90+
</div>
91+
)
92+
}
93+
94+
function ChatMessageActions() {
95+
const { message } = useChatMessage()
96+
const { isCopied, copy } = useClipboard()
97+
98+
return (
99+
<div
100+
className="absolute right-2 bottom-2 opacity-0 group-hover:opacity-25 cursor-pointer"
101+
onClick={() => copy(message.content)}>
102+
{isCopied ? <CheckIcon /> : <CopyIcon />}
103+
</div>
104+
)
105+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createContext } from "react"
2+
3+
import { Message } from "./types"
4+
5+
export interface ChatMessageContext {
6+
message: Message
7+
isLast: boolean
8+
}
9+
10+
export const chatMessageContext = createContext<ChatMessageContext | null>(null)
11+
12+
export const ChatMessageProvider = chatMessageContext.Provider
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useCallback, useEffect, useRef } from "react"
2+
3+
import { useChatUI } from "./useChatUI"
4+
import { ChatMessage } from "./ChatMessage"
5+
6+
export function ChatMessages() {
7+
const { messages, isLoading, append } = useChatUI()
8+
const containerRef = useRef<HTMLDivElement>(null)
9+
const messageCount = messages.length
10+
11+
const scrollToBottom = useCallback(() => {
12+
if (!containerRef.current) {
13+
return
14+
}
15+
16+
requestAnimationFrame(() => {
17+
containerRef.current?.scrollTo({
18+
top: containerRef.current.scrollHeight,
19+
behavior: "smooth",
20+
})
21+
})
22+
}, [])
23+
24+
useEffect(() => scrollToBottom(), [messageCount, scrollToBottom])
25+
26+
return (
27+
<div ref={containerRef} className="flex flex-col flex-1 min-h-0 overflow-auto relative">
28+
{messages.map((message, index) => (
29+
<ChatMessage
30+
key={index}
31+
message={message}
32+
isHeaderVisible={
33+
!!message.annotations?.length || index === 0 || messages[index - 1].role !== message.role
34+
}
35+
isLast={index === messageCount - 1}
36+
isLoading={isLoading}
37+
append={append}
38+
/>
39+
))}
40+
</div>
41+
)
42+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createContext } from "react"
2+
3+
import { ChatHandler } from "./types"
4+
5+
type ChatContext = ChatHandler & {
6+
assistantName: string
7+
}
8+
9+
export const chatContext = createContext<ChatContext | null>(null)
10+
11+
export const ChatProvider = chatContext.Provider
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./types"
2+
export * from "./Chat"

0 commit comments

Comments
 (0)