show cody context files as an action in the transcript (#50691)

Instead of showing these in a longer message after Cody's response, the
files that Cody read are now shown in a small chip before the response:
`7 files read`. Expanding it shows the first step (searching) and each
file. This UI can be extended to more kinds of plugins/actions in the
future.


![image](https://user-images.githubusercontent.com/1976/232328027-1426d280-d528-4563-9ca6-661fae89ff61.png)

![image](https://user-images.githubusercontent.com/1976/232328045-52aadf35-7a57-4c57-b74e-09f9a69fc055.png)



## Test plan

Open Cody. Run a query that requires it to read files. Confirm they show
up here.
This commit is contained in:
Quinn Slack 2023-04-16 23:55:09 -07:00 committed by GitHub
parent 52e0c4e4a4
commit 72f95daf58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 419 additions and 1098 deletions

View File

@ -32,6 +32,7 @@ ts_project(
"src/chat/Transcript.story.tsx",
"src/chat/Transcript.tsx",
"src/chat/TranscriptItem.tsx",
"src/chat/actions/TranscriptAction.tsx",
"src/chat/fixtures.ts",
"src/chat/inputContext/ChatInputContext.story.tsx",
"src/chat/inputContext/ChatInputContext.tsx",

View File

@ -72,6 +72,7 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
transcriptItemClassName,
humanTranscriptItemClassName,
transcriptItemParticipantClassName,
transcriptActionClassName,
inputRowClassName,
chatInputContextClassName,
chatInputClassName,
@ -148,6 +149,7 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
transcriptItemClassName={transcriptItemClassName}
humanTranscriptItemClassName={humanTranscriptItemClassName}
transcriptItemParticipantClassName={transcriptItemParticipantClassName}
transcriptActionClassName={transcriptActionClassName}
className={styles.transcriptContainer}
/>

View File

@ -1,41 +0,0 @@
.context-files-collapsed {
display: flex;
cursor: pointer;
}
.context-files-expanded {
display: flex;
}
.context-files-toggle-icon {
cursor: pointer;
margin-right: 0.5rem;
}
.context-files-list-title {
cursor: pointer;
font-style: italic;
}
.context-files-list-container {
margin-top: 0;
padding-inline-start: 0;
list-style: none;
}
.context-files-list-container li {
padding: 0.1rem;
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
font-size: 90%;
}
.context-files-collapsed .context-files-toggle-icon {
display: flex;
align-items: flex-start;
}
.context-files-collapsed-text {
display: flex;
align-items: flex-start;
font-style: italic;
}

View File

@ -1,11 +1,10 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useState } from 'react'
import React from 'react'
import { mdiChevronDown, mdiChevronRight } from '@mdi/js'
import classNames from 'classnames'
import { mdiFileDocumentOutline, mdiMagnify } from '@mdi/js'
import styles from './ContextFiles.module.css'
import { pluralize } from '@sourcegraph/common'
import { TranscriptAction } from './actions/TranscriptAction'
export interface FileLinkProps {
path: string
@ -15,60 +14,17 @@ export const ContextFiles: React.FunctionComponent<{
contextFiles: string[]
fileLinkComponent: React.FunctionComponent<FileLinkProps>
className?: string
}> = ({ contextFiles, fileLinkComponent: FileLink, className }) => {
const [isExpanded, setIsExpanded] = useState(false)
if (contextFiles.length === 1) {
return (
<p className={className}>
Cody read{' '}
<span className={styles.contextFile}>
<FileLink path={contextFiles[0]} />
</span>{' '}
to provide an answer.
</p>
)
}
if (isExpanded) {
return (
<div className={classNames(className, styles.contextFilesExpanded)}>
<span className={styles.contextFilesToggleIcon} onClick={() => setIsExpanded(false)}>
<Icon aria-hidden={true} svgPath={mdiChevronDown} />
</span>
<div>
<div className={styles.contextFilesListTitle} onClick={() => setIsExpanded(false)}>
Cody read the following files to provide an answer:
</div>
<ul className={styles.contextFilesListContainer}>
{contextFiles.map(file => (
<li key={file} className={styles.contextFile}>
<FileLink path={file} />
</li>
))}
</ul>
</div>
</div>
)
}
return (
<div className={classNames(className, styles.contextFilesCollapsed)} onClick={() => setIsExpanded(true)}>
<span className={styles.contextFilesToggleIcon}>
<Icon aria-hidden={true} svgPath={mdiChevronRight} />
</span>
<div className={styles.contextFilesCollapsedText}>
<span>
Cody read <span className={styles.contextFile}>{contextFiles[0].split('/').pop()}</span> and{' '}
{contextFiles.length - 1} other {contextFiles.length > 2 ? 'files' : 'file'} to provide an answer.
</span>
</div>
</div>
)
}
const Icon: React.FC<{ svgPath: string }> = ({ svgPath }) => (
<svg role="img" height={24} width={24} viewBox="0 0 24 24" fill="currentColor">
<path d={svgPath} />
</svg>
}> = ({ contextFiles, fileLinkComponent: FileLink, className }) => (
<TranscriptAction
title={{ verb: 'Read', object: `${contextFiles.length} ${pluralize('file', contextFiles.length)}` }}
steps={[
{ verb: 'Searched', object: 'entire codebase for relevant files', icon: mdiMagnify },
...contextFiles.map(file => ({
verb: 'Read',
object: <FileLink path={file} />,
icon: mdiFileDocumentOutline,
})),
]}
className={className}
/>
)

View File

@ -4,6 +4,7 @@
--row-foreground-color: black;
--row-participant-name-color: #777;
--row-border-color: #cccccc;
--transcript-action-background-color: #00000011;
}
:global(.theme-dark) {
@ -12,6 +13,7 @@
--row-foreground-color: #ffffffee;
--row-participant-name-color: #ffffff88;
--row-border-color: #ffffff33;
--transcript-action-background-color: #ffffff11;
}
.transcript-item {
@ -35,3 +37,7 @@
.transcript-item-participant {
color: var(--row-participant-name-color);
}
.transcript-action {
background-color: var(--transcript-action-background-color);
}

View File

@ -46,6 +46,7 @@ export const Simple: ComponentStoryObj<typeof Transcript> = {
transcriptItemClassName={styles.transcriptItem}
humanTranscriptItemClassName={styles.humanTranscriptItem}
transcriptItemParticipantClassName={styles.transcriptItemParticipant}
transcriptActionClassName={styles.transcriptAction}
/>
),
}

View File

@ -25,6 +25,7 @@ export const Transcript: React.FunctionComponent<
transcriptItemClassName,
humanTranscriptItemClassName,
transcriptItemParticipantClassName,
transcriptActionClassName,
}) => {
const transcriptContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -71,6 +72,7 @@ export const Transcript: React.FunctionComponent<
transcriptItemClassName={transcriptItemClassName}
humanTranscriptItemClassName={humanTranscriptItemClassName}
transcriptItemParticipantClassName={transcriptItemParticipantClassName}
transcriptActionClassName={transcriptActionClassName}
/>
))}
{messageInProgress && messageInProgress.speaker === 'assistant' && (
@ -81,6 +83,7 @@ export const Transcript: React.FunctionComponent<
codeBlocksCopyButtonClassName={codeBlocksCopyButtonClassName}
transcriptItemClassName={transcriptItemClassName}
transcriptItemParticipantClassName={transcriptItemParticipantClassName}
transcriptActionClassName={transcriptActionClassName}
/>
)}
</div>

View File

@ -46,6 +46,14 @@
width: 1rem;
}
.actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(var(--spacing) * 0.5);
padding: 0 var(--spacing);
}
.content {
padding: 0 var(--spacing) var(--spacing) var(--spacing);
word-break: break-word;
@ -63,8 +71,3 @@
.content > div:first-child > *:last-child {
margin-bottom: 0;
}
.context-files {
margin-top: var(--spacing);
font-size: 0.9rem;
}

View File

@ -20,6 +20,7 @@ export interface TranscriptItemClassNames {
humanTranscriptItemClassName?: string
transcriptItemParticipantClassName?: string
codeBlocksCopyButtonClassName?: string
transcriptActionClassName?: string
}
/**
@ -39,6 +40,7 @@ export const TranscriptItem: React.FunctionComponent<
humanTranscriptItemClassName,
transcriptItemParticipantClassName,
codeBlocksCopyButtonClassName,
transcriptActionClassName,
}) => (
<div
className={classNames(
@ -58,18 +60,18 @@ export const TranscriptItem: React.FunctionComponent<
)}
</h2>
</header>
{message.contextFiles && message.contextFiles.length > 0 && (
<div className={styles.actions}>
<ContextFiles
contextFiles={message.contextFiles}
fileLinkComponent={fileLinkComponent}
className={transcriptActionClassName}
/>
</div>
)}
<div className={classNames(styles.content)}>
{message.displayText ? (
<>
<CodeBlocks displayText={message.displayText} copyButtonClassName={codeBlocksCopyButtonClassName} />
{message.contextFiles && message.contextFiles.length > 0 && (
<ContextFiles
contextFiles={message.contextFiles}
fileLinkComponent={fileLinkComponent}
className={styles.contextFiles}
/>
)}
</>
<CodeBlocks displayText={message.displayText} copyButtonClassName={codeBlocksCopyButtonClassName} />
) : inProgress ? (
<BlinkingCursor />
) : null}

View File

@ -0,0 +1,60 @@
.container {
display: inline-block;
padding: 0.4rem 0.4rem 0.3rem 0.7rem;
font-size: 0.9rem;
}
.container-open {
padding-bottom: 0.6rem;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 0.75rem;
}
.open-close-button {
display: inline-flex;
align-items: center;
gap: 0.2rem;
cursor: pointer;
padding: 0;
appearance: none;
background: none;
border: none;
outline: none;
color: currentColor;
font-family: inherit;
}
.open-close-icon {
opacity: 0.8;
width: 1.25rem;
height: 1.25rem;
}
.steps {
display: flex;
flex-direction: column;
gap: 0.75rem;
list-style-type: none;
margin: 0;
padding: 0;
}
.step {
display: flex;
align-items: center;
gap: 0.3rem;
}
.step-icon {
width: 1.25rem;
height: 1.25rem;
opacity: 0.5;
flex-shrink: 0;
margin-right: 0.3rem;
}
.step-object {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex: 1;
}

View File

@ -0,0 +1,62 @@
import React, { useState } from 'react'
import { mdiChevronDown, mdiChevronUp } from '@mdi/js'
import classNames from 'classnames'
import styles from './TranscriptAction.module.css'
export interface TranscriptActionStep {
verb: string
object: string | JSX.Element
/**
* The SVG path of an icon.
*
* @example mdiSearchWeb
*/
icon?: string
}
export const TranscriptAction: React.FunctionComponent<{
title: string | { verb: string; object: string }
steps: TranscriptActionStep[]
className?: string
}> = ({ title, steps, className }) => {
const [open, setOpen] = useState(false)
return (
<div className={classNames(className, styles.container, open && styles.containerOpen)}>
<button type="button" onClick={() => setOpen(!open)} className={styles.openCloseButton}>
{typeof title === 'string' ? (
title
) : (
<span>
{title.verb} <strong>{title.object}</strong>
</span>
)}
<Icon
aria-hidden={true}
svgPath={open ? mdiChevronUp : mdiChevronDown}
className={styles.openCloseIcon}
/>
</button>
{open && (
<ol className={styles.steps}>
{steps.map((step, index) => (
// eslint-disable-next-line react/no-array-index-key
<li key={index} className={styles.step}>
{step.icon && <Icon svgPath={step.icon} className={styles.stepIcon} />} {step.verb}{' '}
<span className={styles.stepObject}>{step.object}</span>
</li>
))}
</ol>
)}
</div>
)
}
const Icon: React.FunctionComponent<{ svgPath: string; className?: string }> = ({ svgPath, className }) => (
<svg role="img" height={24} width={24} viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d={svgPath} />
</svg>
)

View File

@ -12,10 +12,10 @@
"exclude": ["out", "dist"],
"references": [
{
"path": "../common",
"path": "../cody-shared",
},
{
"path": "../cody-shared",
"path": "../common",
},
],
}

View File

@ -35,6 +35,10 @@
color: #ffffff;
}
.transcript-action {
background-color: Field;
}
.input-row {
background-color: Canvas;
border-top: solid 1px ButtonBorder;

View File

@ -38,6 +38,7 @@ export const Chat: React.FunctionComponent<
transcriptItemClassName={styles.transcriptItem}
humanTranscriptItemClassName={styles.humanTranscriptItem}
transcriptItemParticipantClassName={styles.transcriptItemParticipant}
transcriptActionClassName={styles.transcriptAction}
inputRowClassName={styles.inputRow}
chatInputClassName={styles.chatInput}
/>

View File

@ -38,6 +38,10 @@ body[data-vscode-theme-kind='vscode-high-contrast'] .human-transcript-item {
color: var(--vscode-input-foreground);
}
.transcript-action {
background-color: var(--code-background);
}
.code-blocks-copy-button {
color: var(--vscode-button-secondaryForeground);
background-color: var(--vscode-button-secondaryBackground);

View File

@ -54,6 +54,7 @@ export const Chat: React.FunctionComponent<React.PropsWithChildren<ChatboxProps>
transcriptItemClassName={styles.transcriptItem}
humanTranscriptItemClassName={styles.humanTranscriptItem}
transcriptItemParticipantClassName={styles.transcriptItemParticipant}
transcriptActionClassName={styles.transcriptAction}
inputRowClassName={styles.inputRow}
chatInputContextClassName={styles.chatInputContext}
chatInputClassName={styles.chatInputClassName}

View File

@ -5,7 +5,7 @@
cursor: pointer;
font-size: inherit;
text-decoration: underline;
display: inline;
display: contents; /* so our parent can tell us to truncate with ellipsis */
padding: 0;
margin: 0;
text-align: left;

File diff suppressed because it is too large Load Diff