Skip to content

Commit d53e0b7

Browse files
authored
Put streaming visual cue at the end of the content (#1659)
1 parent aab145d commit d53e0b7

File tree

6 files changed

+52
-56
lines changed

6 files changed

+52
-56
lines changed

js/chat/chat.scss

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,6 @@ shiny-chat-container {
4040
.message-content {
4141
align-self: center;
4242
}
43-
.message-streaming-icon {
44-
display: none;
45-
opacity: 0;
46-
}
47-
&[streaming] .message-streaming-icon {
48-
display: block;
49-
animation-delay: 2s;
50-
animation-name: fade-in;
51-
animation-duration: 10ms;
52-
animation-fill-mode: forwards;
53-
}
5443
}
5544

5645
/* Align the user message to the right */
@@ -154,13 +143,3 @@ pre:has(.code-copy-button) {
154143
background-color: var(--bs-success, #198754);
155144
}
156145
}
157-
158-
/* Keyframes for the fading spinner */
159-
@keyframes fade-in {
160-
0% {
161-
opacity: 0;
162-
}
163-
100% {
164-
opacity: 1;
165-
}
166-
}

js/chat/chat.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,17 @@ const ICONS = {
5757
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
5858
dots_fade:
5959
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>',
60-
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/bouncing-ball.svg
61-
ball_bounce:
62-
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_rXNP{animation:spinner_YeBj .8s infinite; opacity:.8}@keyframes spinner_YeBj{0%{animation-timing-function:cubic-bezier(0.33,0,.66,.33);cy:5px}46.875%{cy:20px;rx:4px;ry:4px}50%{animation-timing-function:cubic-bezier(0.33,.66,.66,1);cy:20.5px;rx:4.8px;ry:3px}53.125%{rx:4px;ry:4px}100%{cy:5px}}</style><ellipse class="spinner_rXNP" cx="12" cy="5" rx="4" ry="4"/></svg>',
60+
dot: '<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" class="chat-streaming-dot" style="margin-left:.25em;margin-top:-.25em"><circle cx="6" cy="6" r="6"/></svg>',
6361
};
6462

63+
function createSVGIcon(icon: string): HTMLElement {
64+
const parser = new DOMParser();
65+
const svgDoc = parser.parseFromString(icon, "image/svg+xml");
66+
return svgDoc.documentElement;
67+
}
68+
69+
const SVG_DOT = createSVGIcon(ICONS.dot);
70+
6571
const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
6672
el.dispatchEvent(
6773
new CustomEvent("shiny-chat-request-scroll", {
@@ -127,17 +133,29 @@ class ChatMessage extends LightElement {
127133
return html`
128134
<div class="message-icon">${unsafeHTML(icon)}</div>
129135
<div class="message-content">${content}</div>
130-
<div class="message-streaming-icon">${unsafeHTML(ICONS.ball_bounce)}</div>
131136
`;
132137
}
133138

134139
updated(changedProperties: Map<string, unknown>): void {
135140
if (changedProperties.has("content")) {
136141
this.#highlightAndCodeCopy();
142+
if (this.streaming) this.#appendStreamingDot();
137143
// It's important that the scroll request happens at this point in time, since
138144
// otherwise, the content may not be fully rendered yet
139145
requestScroll(this, this.streaming);
140146
}
147+
if (changedProperties.has("streaming")) {
148+
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
149+
}
150+
}
151+
152+
#appendStreamingDot(): void {
153+
const content = this.querySelector(".message-content") as HTMLElement;
154+
content.lastElementChild?.appendChild(SVG_DOT);
155+
}
156+
157+
#removeStreamingDot(): void {
158+
this.querySelector(".message-content svg.chat-streaming-dot")?.remove();
141159
}
142160

143161
// Highlight code blocks after the element is rendered
@@ -416,6 +434,7 @@ class ChatContainer extends LightElement {
416434
lastMessage.setAttribute("content", message.content);
417435

418436
if (message.chunk_type === "message_end") {
437+
this.lastMessage?.removeAttribute("streaming");
419438
this.#finalizeMessage();
420439
}
421440
}
@@ -441,7 +460,6 @@ class ChatContainer extends LightElement {
441460

442461
#finalizeMessage(): void {
443462
this.input.disabled = false;
444-
this.lastMessage?.removeAttribute("streaming");
445463
}
446464

447465
#onRequestScroll(event: CustomEvent<requestScrollEvent>): void {

shiny/www/py-shiny/chat/chat.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)