Skip to content

Commit c35a0b4

Browse files
committed
feat(chat): expose isComposing prop & add tests
1 parent 111c0b9 commit c35a0b4

4 files changed

Lines changed: 149 additions & 88 deletions

File tree

src/components/chat/chat-message-list.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { consume } from '@lit/context';
2-
import { LitElement, html } from 'lit';
2+
import { html, LitElement } from 'lit';
33
import { repeat } from 'lit/directives/repeat.js';
44
import { chatContext } from '../common/context.js';
55
import { registerComponent } from '../common/definitions/register.js';
6-
import IgcChatMessageComponent from './chat-message.js';
76
import type IgcChatComponent from './chat.js';
7+
import IgcChatMessageComponent from './chat-message.js';
88
import { styles } from './themes/message-list.base.css.js';
99
import type { IgcMessage } from './types.js';
1010

@@ -87,6 +87,16 @@ export default class IgcChatMessageListComponent extends LitElement {
8787
}
8888
}
8989

90+
protected *renderLoadingTemplate() {
91+
yield html` ${this._chat?.options?.templates?.composingIndicatorTemplate
92+
? this._chat.options.templates.composingIndicatorTemplate
93+
: html`<div class="typing-indicator">
94+
<div class="typing-dot"></div>
95+
<div class="typing-dot"></div>
96+
<div class="typing-dot"></div>
97+
</div>`}`;
98+
}
99+
90100
protected override render() {
91101
const groupedMessages = this.groupMessagesByDate(
92102
this._chat?.messages ?? []
@@ -109,16 +119,7 @@ export default class IgcChatMessageListComponent extends LitElement {
109119
`
110120
)}
111121
${
112-
''
113-
// this.isAiResponding
114-
// ? html`
115-
// <div class="typing-indicator">
116-
// <div class="typing-dot"></div>
117-
// <div class="typing-dot"></div>
118-
// <div class="typing-dot"></div>
119-
// </div>
120-
// `
121-
// : ''
122+
this._chat?.options?.isComposing ? this.renderLoadingTemplate() : ''
122123
}
123124
</div>
124125
</div>

src/components/chat/chat.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ describe('Chat', () => {
1919
: html``;
2020
};
2121

22+
const composingIndicatorTemplate = html`<span>loading...</span>`;
23+
2224
const attachmentTemplate = (attachments: any[]) => {
2325
return html`${attachments.map((attachment) => {
2426
return html`<igc-chip><span>${attachment.name}</span></igc-chip>`;
@@ -486,6 +488,34 @@ describe('Chat', () => {
486488
</div>`
487489
);
488490
});
491+
492+
it('should render composing indicator if `isComposing` is true', async () => {
493+
chat.messages = [messages[0]];
494+
chat.options = {
495+
isComposing: true,
496+
};
497+
await elementUpdated(chat);
498+
499+
const messageContainer = chat.shadowRoot
500+
?.querySelector('igc-chat-message-list')
501+
?.shadowRoot?.querySelector('.message-list');
502+
503+
expect(chat.messages.length).to.equal(1);
504+
expect(messageContainer).dom.to.equal(
505+
`<div class="message-list">
506+
<igc-chat-message>
507+
</igc-chat-message>
508+
<div class="typing-indicator">
509+
<div class="typing-dot">
510+
</div>
511+
<div class="typing-dot">
512+
</div>
513+
<div class="typing-dot">
514+
</div>
515+
</div>
516+
</div>`
517+
);
518+
});
489519
});
490520

491521
describe('Slots', () => {
@@ -629,6 +659,29 @@ describe('Chat', () => {
629659
}
630660
});
631661
});
662+
663+
it('should render composingIndicatorTemplate', async () => {
664+
chat.messages = [messages[0]];
665+
chat.options = {
666+
isComposing: true,
667+
templates: {
668+
composingIndicatorTemplate: composingIndicatorTemplate,
669+
},
670+
};
671+
await elementUpdated(chat);
672+
const messageContainer = chat.shadowRoot
673+
?.querySelector('igc-chat-message-list')
674+
?.shadowRoot?.querySelector('.message-list');
675+
676+
expect(chat.messages.length).to.equal(1);
677+
expect(messageContainer).dom.to.equal(
678+
`<div class="message-list">
679+
<igc-chat-message>
680+
</igc-chat-message>
681+
<span>loading...</span>
682+
</div>`
683+
);
684+
});
632685
});
633686

634687
describe('Interactions', () => {

src/components/chat/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type IgcChatOptions = {
3030
hideUserName?: boolean;
3131
disableAutoScroll?: boolean;
3232
disableAttachments?: boolean;
33+
isComposing?: boolean;
3334
/**
3435
* The accepted files that could be attached.
3536
* Defines the file types as a list of comma-separated values that the file input should accept.
@@ -46,6 +47,7 @@ export type IgcChatTemplates = {
4647
attachmentActionsTemplate?: AttachmentTemplate;
4748
attachmentContentTemplate?: AttachmentTemplate;
4849
messageActionsTemplate?: MessageActionsTemplate;
50+
composingIndicatorTemplate?: TemplateResult;
4951
};
5052

5153
export const attachmentIcon =

stories/chat.stories.ts

Lines changed: 81 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,14 @@ const messageActionsTemplate = (msg: any) => {
105105
: html``;
106106
};
107107

108+
const _composingIndicatorTemplate = html`<span>LOADING...</span>`;
109+
108110
const ai_chat_options = {
109111
headerText: 'Chat',
110112
suggestions: ['Hello', 'Hi', 'Generate an image of a pig!'],
111113
templates: {
112114
messageActionsTemplate: messageActionsTemplate,
115+
//composingIndicatorTemplate: _composingIndicatorTemplate,
113116
},
114117
};
115118

@@ -414,92 +417,94 @@ async function handleAIMessageSend(e: CustomEvent) {
414417
return;
415418
}
416419

417-
chat.options = { ...ai_chat_options, suggestions: [] };
418-
419-
let response: any;
420-
let responseText = '';
421-
const attachments: IgcMessageAttachment[] = [];
422-
const botResponse: IgcMessage = {
423-
id: Date.now().toString(),
424-
text: responseText,
425-
sender: 'bot',
426-
timestamp: new Date(),
427-
};
420+
chat.options = { ...ai_chat_options, suggestions: [], isComposing: true };
421+
setTimeout(async () => {
422+
chat.options = { ...ai_chat_options, suggestions: [], isComposing: false };
423+
let response: any;
424+
let responseText = '';
425+
const attachments: IgcMessageAttachment[] = [];
426+
const botResponse: IgcMessage = {
427+
id: Date.now().toString(),
428+
text: responseText,
429+
sender: 'bot',
430+
timestamp: new Date(),
431+
};
428432

429-
userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] });
433+
userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] });
430434

431-
if (newMessage.attachments && newMessage.attachments.length > 0) {
432-
for (const attachment of newMessage.attachments) {
433-
if (attachment.file) {
434-
const filePart = fileToGenerativePart(
435-
await attachment.file.arrayBuffer(),
436-
attachment.file.type
437-
);
438-
userMessages.push({ role: 'user', parts: [filePart] });
435+
if (newMessage.attachments && newMessage.attachments.length > 0) {
436+
for (const attachment of newMessage.attachments) {
437+
if (attachment.file) {
438+
const filePart = fileToGenerativePart(
439+
await attachment.file.arrayBuffer(),
440+
attachment.file.type
441+
);
442+
userMessages.push({ role: 'user', parts: [filePart] });
443+
}
439444
}
440445
}
441-
}
442446

443-
if (newMessage.text.includes('image')) {
444-
response = await ai.models.generateContent({
445-
model: 'gemini-2.0-flash-preview-image-generation',
446-
contents: userMessages,
447-
config: {
448-
responseModalities: [Modality.TEXT, Modality.IMAGE],
449-
},
450-
});
451-
452-
for (const part of response?.candidates?.[0]?.content?.parts || []) {
453-
// Based on the part type, either show the text or save the image
454-
if (part.text) {
455-
responseText = part.text;
456-
} else if (part.inlineData) {
457-
const _imageData = part.inlineData.data;
458-
const byteCharacters = atob(_imageData);
459-
const byteNumbers = new Array(byteCharacters.length);
460-
for (let i = 0; i < byteCharacters.length; i++) {
461-
byteNumbers[i] = byteCharacters.charCodeAt(i);
447+
if (newMessage.text.includes('image')) {
448+
response = await ai.models.generateContent({
449+
model: 'gemini-2.0-flash-preview-image-generation',
450+
contents: userMessages,
451+
config: {
452+
responseModalities: [Modality.TEXT, Modality.IMAGE],
453+
},
454+
});
455+
456+
for (const part of response?.candidates?.[0]?.content?.parts || []) {
457+
// Based on the part type, either show the text or save the image
458+
if (part.text) {
459+
responseText = part.text;
460+
} else if (part.inlineData) {
461+
const _imageData = part.inlineData.data;
462+
const byteCharacters = atob(_imageData);
463+
const byteNumbers = new Array(byteCharacters.length);
464+
for (let i = 0; i < byteCharacters.length; i++) {
465+
byteNumbers[i] = byteCharacters.charCodeAt(i);
466+
}
467+
const byteArray = new Uint8Array(byteNumbers);
468+
const type = part.inlineData.type || 'image/png';
469+
const blob = new Blob([byteArray], { type: type });
470+
const file = new File([blob], 'generated_image.png', {
471+
type: type,
472+
});
473+
const attachment: IgcMessageAttachment = {
474+
id: Date.now().toString(),
475+
name: 'generated_image.png',
476+
type: 'image',
477+
url: URL.createObjectURL(file),
478+
file: file,
479+
};
480+
attachments.push(attachment);
462481
}
463-
const byteArray = new Uint8Array(byteNumbers);
464-
const type = part.inlineData.type || 'image/png';
465-
const blob = new Blob([byteArray], { type: type });
466-
const file = new File([blob], 'generated_image.png', {
467-
type: type,
468-
});
469-
const attachment: IgcMessageAttachment = {
470-
id: Date.now().toString(),
471-
name: 'generated_image.png',
472-
type: 'image',
473-
url: URL.createObjectURL(file),
474-
file: file,
475-
};
476-
attachments.push(attachment);
477482
}
478-
}
479483

480-
botResponse.text = responseText;
481-
botResponse.attachments = attachments;
482-
chat.messages = [...chat.messages, botResponse];
483-
} else {
484-
chat.messages = [...chat.messages, botResponse];
485-
response = await ai.models.generateContentStream({
486-
model: 'gemini-2.0-flash',
487-
contents: userMessages,
488-
config: {
489-
responseModalities: [Modality.TEXT],
490-
},
491-
});
484+
botResponse.text = responseText;
485+
botResponse.attachments = attachments;
486+
chat.messages = [...chat.messages, botResponse];
487+
} else {
488+
chat.messages = [...chat.messages, botResponse];
489+
response = await ai.models.generateContentStream({
490+
model: 'gemini-2.0-flash',
491+
contents: userMessages,
492+
config: {
493+
responseModalities: [Modality.TEXT],
494+
},
495+
});
492496

493-
const lastMessageIndex = chat.messages.length - 1;
494-
for await (const chunk of response) {
495-
chat.messages[lastMessageIndex] = {
496-
...chat.messages[lastMessageIndex],
497-
text: `${chat.messages[lastMessageIndex].text}${chunk.text}`,
498-
};
499-
chat.messages = [...chat.messages];
497+
const lastMessageIndex = chat.messages.length - 1;
498+
for await (const chunk of response) {
499+
chat.messages[lastMessageIndex] = {
500+
...chat.messages[lastMessageIndex],
501+
text: `${chat.messages[lastMessageIndex].text}${chunk.text}`,
502+
};
503+
chat.messages = [...chat.messages];
504+
}
505+
chat.options = { ...ai_chat_options, suggestions: ['Thank you!'] };
500506
}
501-
chat.options = { ...ai_chat_options, suggestions: ['Thank you!'] };
502-
}
507+
}, 2000);
503508
}
504509

505510
export const Basic: Story = {

0 commit comments

Comments
 (0)