cody-ui chat refactors (#50682)

No behavior change.

- extract cody-ui ChatMessages and add storybook
- split apart cody-ui ChatMessages component
- remove duplicate cody vscode CSS vars
This commit is contained in:
Quinn Slack 2023-04-15 13:10:15 -07:00 committed by GitHub
parent df74999810
commit 476ca9e63d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 345 additions and 219 deletions

View File

@ -27,6 +27,10 @@ ts_project(
"src/Chat.tsx",
"src/Terms.tsx",
"src/Tips.tsx",
"src/chat/ChatMessageLoading.tsx",
"src/chat/ChatMessageRow.tsx",
"src/chat/ChatMessages.story.tsx",
"src/chat/ChatMessages.tsx",
"src/chat/CodeBlocks.tsx",
"src/chat/ContextFiles.tsx",
"src/globals.d.ts",
@ -43,6 +47,7 @@ ts_project(
deps = [
":node_modules/@sourcegraph/cody-shared",
"//:node_modules/@mdi/js",
"//:node_modules/@storybook/react", #keep
"//:node_modules/@types/classnames",
"//:node_modules/@types/react",
"//:node_modules/classnames",

View File

@ -15,7 +15,8 @@
"build": "tsc -b",
"lint": "pnpm run lint:js",
"lint:js": "eslint --cache '**/*.[tj]s?(x)'",
"test": "jest"
"test": "jest",
"storybook": "STORIES_GLOB='client/cody-ui/src/**/*.story.tsx' pnpm --filter @sourcegraph/storybook run start"
},
"dependencies": {
"@sourcegraph/cody-shared": "workspace:*"

View File

@ -1,7 +1,3 @@
:root {
--chat-bubble-border-radius: 16px;
}
.inner-container {
display: flex;
flex-direction: column;
@ -12,102 +8,6 @@
flex: 1;
}
.bubble-container {
padding: 0;
}
.bubble-row {
display: flex;
padding: 1rem;
}
.human-bubble-row {
justify-content: end;
}
.bot-bubble-row {
justify-content: start;
}
.bubble {
min-width: 0;
max-width: min(93%, 800px);
}
.bubble-content {
padding: 1rem;
border-top-right-radius: var(--chat-bubble-border-radius);
border-top-left-radius: var(--chat-bubble-border-radius);
word-break: break-word;
line-height: 150%;
}
.bubble-content *:first-child {
margin-top: 0 !important;
}
.bubble-content *:last-child {
margin-bottom: 0 !important;
}
.bubble-content pre {
padding: 0.5rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.human-bubble-content {
border-bottom-right-radius: 0;
border-bottom-left-radius: var(--chat-bubble-border-radius);
}
.bot-bubble-content {
border-bottom-right-radius: var(--chat-bubble-border-radius);
border-bottom-left-radius: 0;
}
.bubble-footer {
display: flex;
margin-top: 0.5rem;
align-content: center;
justify-content: space-between;
}
.human-bubble-footer {
justify-content: flex-end;
}
.bubble-footer-timestamp {
margin-top: 0.5rem;
align-self: center;
}
@keyframes blink {
50% {
background-color: transparent;
}
}
.bubble-loader {
display: flex;
justify-content: space-between;
}
.bubble-loader-dot {
animation: 1s blink infinite;
width: 0.5rem;
height: 0.5rem;
border-radius: 0.5rem;
}
.bubble-loader-dot:nth-child(2) {
animation-delay: 250ms;
}
.bubble-loader-dot:nth-child(3) {
animation-delay: 500ms;
}
.input-row {
display: flex;
padding: 1rem;

View File

@ -2,11 +2,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import classNames from 'classnames'
import { renderMarkdown } from '@sourcegraph/cody-shared/src/chat/markdown'
import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages'
import { CodeBlocks } from './chat/CodeBlocks'
import { ContextFiles, FileLinkProps } from './chat/ContextFiles'
import { ChatMessages, ChatMessagesClassNames } from './chat/ChatMessages'
import { FileLinkProps } from './chat/ContextFiles'
import { Tips } from './Tips'
import styles from './Chat.module.css'
@ -28,16 +27,8 @@ interface ChatProps extends ChatClassNames {
className?: string
}
interface ChatClassNames {
interface ChatClassNames extends ChatMessagesClassNames {
transcriptContainerClassName?: string
bubbleContentClassName?: string
bubbleClassName?: string
bubbleRowClassName?: string
humanBubbleContentClassName?: string
botBubbleContentClassName?: string
codeBlocksCopyButtonClassName?: string
bubbleFooterClassName?: string
bubbleLoaderDotClassName?: string
inputRowClassName?: string
chatInputClassName?: string
}
@ -141,8 +132,6 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
[inputHistory, onChatSubmit, formInput, historyIndex, setFormInput]
)
const getBubbleClassName = (speaker: string): string => (speaker === 'human' ? 'human' : 'bot')
useEffect(() => {
if (transcriptContainerRef.current) {
// Only scroll if the user didn't scroll up manually more than the scrolling threshold.
@ -172,103 +161,19 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
<Tips recommendations={tipsRecommendations} after={afterTips} />
)}
{transcript.length > 0 && (
<div className={styles.bubbleContainer}>
{transcript.map((message, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`message-${index}`}
className={classNames(
styles.bubbleRow,
bubbleRowClassName,
styles[`${getBubbleClassName(message.speaker)}BubbleRow`]
)}
>
<div className={classNames(styles.bubble, bubbleClassName)}>
<div
className={classNames(
styles.bubbleContent,
styles[`${getBubbleClassName(message.speaker)}BubbleContent`],
bubbleContentClassName,
message.speaker === 'human'
? humanBubbleContentClassName
: botBubbleContentClassName
)}
>
{message.displayText && (
<CodeBlocks
displayText={message.displayText}
copyButtonClassName={codeBlocksCopyButtonClassName}
/>
)}
{message.contextFiles && message.contextFiles.length > 0 && (
<ContextFiles
contextFiles={message.contextFiles}
fileLinkComponent={fileLinkComponent}
/>
)}
</div>
<div
className={classNames(
styles.bubbleFooter,
styles[`${getBubbleClassName(message.speaker)}BubbleFooter`],
bubbleFooterClassName
)}
>
<div className={styles.bubbleFooterTimestamp}>{`${
message.speaker === 'assistant' ? 'Cody' : 'Me'
} · ${message.timestamp}`}</div>
</div>
</div>
</div>
))}
{messageInProgress && messageInProgress.speaker === 'assistant' && (
<div className={classNames(styles.bubbleRow, styles.botBubbleRow)}>
<div className={styles.bubble}>
<div
className={classNames(
styles.bubbleContent,
styles.botBubbleContent,
bubbleContentClassName,
botBubbleContentClassName
)}
>
{messageInProgress.displayText ? (
<p
dangerouslySetInnerHTML={{
__html: renderMarkdown(messageInProgress.displayText),
}}
/>
) : (
<div className={styles.bubbleLoader}>
<div
className={classNames(
styles.bubbleLoaderDot,
bubbleLoaderDotClassName
)}
/>
<div
className={classNames(
styles.bubbleLoaderDot,
bubbleLoaderDotClassName
)}
/>
<div
className={classNames(
styles.bubbleLoaderDot,
bubbleLoaderDotClassName
)}
/>
</div>
)}
</div>
<div className={styles.bubbleFooter}>
<span>Cody is typing...</span>
</div>
</div>
</div>
)}
</div>
<ChatMessages
messageInProgress={messageInProgress}
transcript={transcript}
fileLinkComponent={fileLinkComponent}
bubbleContentClassName={bubbleContentClassName}
bubbleClassName={bubbleClassName}
bubbleRowClassName={bubbleRowClassName}
humanBubbleContentClassName={humanBubbleContentClassName}
botBubbleContentClassName={botBubbleContentClassName}
codeBlocksCopyButtonClassName={codeBlocksCopyButtonClassName}
bubbleFooterClassName={bubbleFooterClassName}
bubbleLoaderDotClassName={bubbleLoaderDotClassName}
/>
)}
</div>

View File

@ -0,0 +1,25 @@
@keyframes blink {
50% {
background-color: transparent;
}
}
.bubble-loader {
display: flex;
justify-content: space-between;
}
.bubble-loader-dot {
animation: 1s blink infinite;
width: 0.5rem;
height: 0.5rem;
border-radius: 0.5rem;
}
.bubble-loader-dot:nth-child(2) {
animation-delay: 250ms;
}
.bubble-loader-dot:nth-child(3) {
animation-delay: 500ms;
}

View File

@ -0,0 +1,15 @@
import React from 'react'
import classNames from 'classnames'
import styles from './ChatMessageLoading.module.css'
export const ChatMessageLoading: React.FunctionComponent<{ bubbleLoaderDotClassName?: string }> = ({
bubbleLoaderDotClassName,
}) => (
<div className={styles.bubbleLoader}>
<div className={classNames(styles.bubbleLoaderDot, bubbleLoaderDotClassName)} />
<div className={classNames(styles.bubbleLoaderDot, bubbleLoaderDotClassName)} />
<div className={classNames(styles.bubbleLoaderDot, bubbleLoaderDotClassName)} />
</div>
)

View File

@ -0,0 +1,69 @@
:root {
--chat-bubble-border-radius: 16px;
}
.bubble-row {
display: flex;
padding: 1rem;
}
.human-bubble-row {
justify-content: end;
}
.bot-bubble-row {
justify-content: start;
}
.bubble {
min-width: 0;
max-width: min(93%, 800px);
}
.bubble-content {
padding: 1rem;
border-top-right-radius: var(--chat-bubble-border-radius);
border-top-left-radius: var(--chat-bubble-border-radius);
word-break: break-word;
line-height: 150%;
}
.bubble-content *:first-child {
margin-top: 0 !important;
}
.bubble-content *:last-child {
margin-bottom: 0 !important;
}
.bubble-content pre {
padding: 0.5rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.human-bubble-content {
border-bottom-right-radius: 0;
border-bottom-left-radius: var(--chat-bubble-border-radius);
}
.bot-bubble-content {
border-bottom-right-radius: var(--chat-bubble-border-radius);
border-bottom-left-radius: 0;
}
.bubble-footer {
display: flex;
margin-top: 0.5rem;
align-content: center;
justify-content: space-between;
}
.human-bubble-footer {
justify-content: flex-end;
}
.bubble-footer-timestamp {
margin-top: 0.5rem;
align-self: center;
}

View File

@ -0,0 +1,99 @@
import React from 'react'
import classNames from 'classnames'
import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages'
import { ChatMessageLoading } from './ChatMessageLoading'
import { CodeBlocks } from './CodeBlocks'
import { ContextFiles, FileLinkProps } from './ContextFiles'
import styles from './ChatMessageRow.module.css'
export interface ChatMessageRowClassNames {
bubbleContentClassName?: string
bubbleClassName?: string
bubbleRowClassName?: string
humanBubbleContentClassName?: string
botBubbleContentClassName?: string
codeBlocksCopyButtonClassName?: string
bubbleFooterClassName?: string
bubbleLoaderDotClassName?: string
}
export const ChatMessageRow: React.FunctionComponent<
{
message: ChatMessage
inProgress: boolean
fileLinkComponent: React.FunctionComponent<FileLinkProps>
className?: string
} & ChatMessageRowClassNames
> = ({
message,
inProgress,
fileLinkComponent,
className,
bubbleContentClassName,
bubbleClassName,
bubbleRowClassName,
humanBubbleContentClassName,
botBubbleContentClassName,
codeBlocksCopyButtonClassName,
bubbleFooterClassName,
bubbleLoaderDotClassName,
}) => {
const classNamePrefix = message.speaker === 'human' ? 'human' : 'bot'
return (
<div
className={classNames(
className,
styles.bubbleRow,
bubbleRowClassName,
styles[`${classNamePrefix}BubbleRow`]
)}
>
<div className={classNames(styles.bubble, bubbleClassName)}>
<div
className={classNames(
styles.bubbleContent,
styles[`${classNamePrefix}BubbleContent`],
bubbleContentClassName,
message.speaker === 'human' ? humanBubbleContentClassName : botBubbleContentClassName
)}
>
{message.displayText ? (
<>
<CodeBlocks
displayText={message.displayText}
copyButtonClassName={codeBlocksCopyButtonClassName}
/>
{message.contextFiles && message.contextFiles.length > 0 && (
<ContextFiles
contextFiles={message.contextFiles}
fileLinkComponent={fileLinkComponent}
/>
)}
</>
) : inProgress ? (
<ChatMessageLoading bubbleLoaderDotClassName={bubbleLoaderDotClassName} />
) : null}
</div>
<div
className={classNames(
styles.bubbleFooter,
styles[`${classNamePrefix}BubbleFooter`],
bubbleFooterClassName
)}
>
{inProgress ? (
<span>Cody is typing...</span>
) : (
<div className={styles.bubbleFooterTimestamp}>{`${
message.speaker === 'assistant' ? 'Cody' : 'Me'
} · ${message.timestamp}`}</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,45 @@
import { Meta, Story } from '@storybook/react'
import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages'
import { ChatMessages } from './ChatMessages'
import { FileLinkProps } from './ContextFiles'
import styles from '../../../cody-web/src/Chat.module.css'
const config: Meta = {
title: 'cody-ui/ChatMessages',
component: ChatMessages,
decorators: [story => <div className="container mt-3 pb-3">{story()}</div>],
parameters: {
component: ChatMessages,
chromatic: {
enableDarkMode: true,
disableSnapshot: false,
},
},
}
export default config
const FIXTURE_TRANSCRIPT: ChatMessage[] = [
{ speaker: 'human', text: 'Hello, world!', displayText: 'Hello, world!', timestamp: '2 min ago' },
{ speaker: 'assistant', text: 'Thank you', displayText: 'Thank you', timestamp: 'now' },
]
export const Simple: Story = () => (
<ChatMessages
messageInProgress={null}
transcript={FIXTURE_TRANSCRIPT}
fileLinkComponent={FileLink}
bubbleContentClassName={styles.bubbleContent}
humanBubbleContentClassName={styles.humanBubbleContent}
botBubbleContentClassName={styles.botBubbleContent}
bubbleFooterClassName={styles.bubbleFooter}
bubbleLoaderDotClassName={styles.bubbleLoaderDot}
/>
)
const FileLink: React.FunctionComponent<FileLinkProps> = ({ path }) => <>{path}</>

View File

@ -0,0 +1,65 @@
import React from 'react'
import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages'
import { ChatMessageRow, ChatMessageRowClassNames } from './ChatMessageRow'
import { FileLinkProps } from './ContextFiles'
export interface ChatMessagesClassNames extends ChatMessageRowClassNames {}
export const ChatMessages: React.FunctionComponent<
{
messageInProgress: ChatMessage | null
transcript: ChatMessage[]
fileLinkComponent: React.FunctionComponent<FileLinkProps>
className?: string
} & ChatMessagesClassNames
> = ({
messageInProgress,
transcript,
fileLinkComponent,
className,
bubbleContentClassName,
bubbleClassName,
bubbleRowClassName,
humanBubbleContentClassName,
botBubbleContentClassName,
codeBlocksCopyButtonClassName,
bubbleFooterClassName,
bubbleLoaderDotClassName,
}) => (
<div className={className}>
{transcript.map((message, index) => (
<ChatMessageRow
// eslint-disable-next-line react/no-array-index-key
key={index}
message={message}
inProgress={false}
fileLinkComponent={fileLinkComponent}
bubbleContentClassName={bubbleContentClassName}
bubbleClassName={bubbleClassName}
bubbleRowClassName={bubbleRowClassName}
humanBubbleContentClassName={humanBubbleContentClassName}
botBubbleContentClassName={botBubbleContentClassName}
codeBlocksCopyButtonClassName={codeBlocksCopyButtonClassName}
bubbleFooterClassName={bubbleFooterClassName}
bubbleLoaderDotClassName={bubbleLoaderDotClassName}
/>
))}
{messageInProgress && messageInProgress.speaker === 'assistant' && (
<ChatMessageRow
message={messageInProgress}
inProgress={true}
fileLinkComponent={fileLinkComponent}
bubbleContentClassName={bubbleContentClassName}
bubbleClassName={bubbleClassName}
bubbleRowClassName={bubbleRowClassName}
humanBubbleContentClassName={humanBubbleContentClassName}
botBubbleContentClassName={botBubbleContentClassName}
codeBlocksCopyButtonClassName={codeBlocksCopyButtonClassName}
bubbleFooterClassName={bubbleFooterClassName}
bubbleLoaderDotClassName={bubbleLoaderDotClassName}
/>
)}
</div>
)

View File

@ -21,9 +21,6 @@ body[data-vscode-theme-kind='vscode-light'],
body[data-vscode-theme-kind='vscode-high-contrast-light'] {
--human-bubble-color: var(--vscode-button-background);
--human-text-color: var(--vscode-button-foreground);
--bot-bubble-color: var(--vscode-input-background);
--bubble-text-color: var(--vscode-input-foreground);
--code-background: var(--vscode-editor-background);
}
html,

View File

@ -43,7 +43,7 @@ const getStoriesGlob = (): string[] => {
// Due to an issue with constant recompiling (https://github.com/storybookjs/storybook/issues/14342)
// we need to make the globs more specific (`(web|shared..)` also doesn't work). Once the above issue
// is fixed, this can be removed and watched for `client/**/*.story.tsx` again.
const directoriesWithStories = ['branded', 'browser', 'jetbrains/webview', 'shared', 'web', 'wildcard']
const directoriesWithStories = ['branded', 'browser', 'jetbrains/webview', 'shared', 'web', 'wildcard', 'cody-ui']
const storiesGlobs = directoriesWithStories.map(packageDirectory =>
path.resolve(ROOT_PATH, `client/${packageDirectory}/src/**/*.story.tsx`)
)
@ -184,8 +184,8 @@ const config: Config = {
})
config.module?.rules.unshift({
test: /\.(sass|scss)$/,
include: /\.module\.(sass|scss)$/,
test: /\.(sass|scss|css)$/,
include: /\.module\.(sass|scss|css)$/,
exclude: storybookPath,
use: getCSSLoaders(
'style-loader',
@ -206,7 +206,7 @@ const config: Config = {
// CSS rule for external plain CSS (skip SASS and PostCSS for build perf)
test: /\.css$/,
// Make sure Storybook styles get handled by the Storybook config
exclude: [storybookPath, monacoEditorPath],
exclude: [storybookPath, monacoEditorPath, /\.module\.css$/],
use: ['@terminus-term/to-string-loader', getBasicCSSLoader()],
})