Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/dist/shinychat.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/dist/shinychat.css.map

Large diffs are not rendered by default.

92 changes: 49 additions & 43 deletions js/dist/shinychat.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions js/dist/shinychat.js.map

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"remark-rehype": "^11.1.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"use-stick-to-bottom": "^1.1.3",
"vfile": "^6.0.3"
},
"devDependencies": {
Expand Down
101 changes: 35 additions & 66 deletions js/src/chat/ChatContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import {
useState,
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react"
import { createPortal } from "react-dom"
import { useStickToBottom } from "use-stick-to-bottom"
import { ChatMessages } from "./ChatMessages"
import { ChatMessage } from "./ChatMessage"
import { MessageErrorBoundary } from "./MessageErrorBoundary"
import { ChatInput, type ChatInputHandle } from "./ChatInput"
import { ScrollToBottomButton } from "./ScrollToBottomButton"
import { ExternalLinkDialogComponent } from "./ExternalLinkDialog"
import { useAutoScroll } from "../markdown/useAutoScroll"
import type { ChatMessageData } from "./state"
import type { ChatTransport } from "../transport/types"

Expand Down Expand Up @@ -50,18 +50,13 @@ export const ChatContainer = forwardRef<
ref,
) {
const chatInputRef = useRef<ChatInputHandle>(null)
const sentinelRef = useRef<HTMLDivElement>(null)

const [inputHasShadow, setInputHasShadow] = useState(false)
const [pendingUrl, setPendingUrl] = useState<string | null>(null)
const pendingUrlRef = useRef<string | null>(null)
pendingUrlRef.current = pendingUrl

const { containerRef: messagesRef, engageStickToBottom } = useAutoScroll({
streaming: !!streamingMessage,
contentDependency: streamingMessage ?? messages,
scrollOnContentChange: true,
})
const { scrollRef, contentRef, isAtBottom, scrollToBottom } =
useStickToBottom({ resize: "smooth" })

useImperativeHandle(ref, () => ({
setInputValue(...args) {
Expand All @@ -72,27 +67,6 @@ export const ChatContainer = forwardRef<
},
}))

useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return

const observer = new IntersectionObserver(
(entries) => {
const addShadow = entries[0]?.intersectionRatio === 0
setInputHasShadow((current) =>
current === addShadow ? current : addShadow,
)
},
{
threshold: [0, 1],
rootMargin: "0px",
},
)

observer.observe(sentinel)
return () => observer.disconnect()
}, [])

const onContainerClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
const target = e.target as HTMLElement
const linkEl = target.closest(
Expand Down Expand Up @@ -186,39 +160,38 @@ export const ChatContainer = forwardRef<
setPendingUrl(null)
}, [])

const isStreaming = !!streamingMessage

// When a new non-streaming message arrives (e.g. append_message), re-engage
// auto-scroll so the user sees it. We don't re-engage for streaming starts
// (chunk_start) because the user should be able to scroll away during streaming.
const prevMessageCountRef = useRef(messages.length)
useEffect(() => {
const prevCount = prevMessageCountRef.current
prevMessageCountRef.current = messages.length
if (messages.length > prevCount && !isStreaming) {
engageStickToBottom()
}
}, [messages.length, isStreaming, engageStickToBottom])
const onSend = useCallback(() => {
scrollToBottom()
}, [scrollToBottom])

return (
<>
<div
className="shiny-chat-messages"
ref={messagesRef}
role="log"
aria-live="polite"
onClick={onMessagesClick}
onKeyDown={onSuggestionKeydown}
>
<ChatMessages messages={messages} iconAssistant={iconAssistant} />
{streamingMessage && (
<MessageErrorBoundary key={streamingMessage.id}>
<ChatMessage
message={streamingMessage}
iconAssistant={iconAssistant}
/>
</MessageErrorBoundary>
)}
<div className="shiny-chat-messages-wrapper">
<div className="shiny-chat-messages" ref={scrollRef}>
<div
className="shiny-chat-messages-content"
ref={contentRef}
role="log"
aria-live="polite"
onClick={onMessagesClick}
onKeyDown={onSuggestionKeydown}
>
<ChatMessages messages={messages} iconAssistant={iconAssistant} />
{streamingMessage && (
<MessageErrorBoundary key={streamingMessage.id}>
<ChatMessage
message={streamingMessage}
iconAssistant={iconAssistant}
/>
</MessageErrorBoundary>
)}
</div>
</div>
<ScrollToBottomButton
isAtBottom={isAtBottom}
scrollToBottom={scrollToBottom}
streaming={!!streamingMessage}
/>
</div>

<div
Expand All @@ -232,16 +205,12 @@ export const ChatContainer = forwardRef<
transport={transport}
inputId={inputId}
disabled={inputDisabled}
hasTopShadow={inputHasShadow}
hasTopShadow={!isAtBottom}
placeholder={inputPlaceholder}
onSend={engageStickToBottom}
onSend={onSend}
/>
</div>

{/* IntersectionObserver sentinel: triggers shadow on the textarea
when messages scroll behind the input area */}
<div ref={sentinelRef} style={{ width: "100%", height: 0 }} />

{pendingUrl &&
createPortal(
<ExternalLinkDialogComponent
Expand Down
31 changes: 31 additions & 0 deletions js/src/chat/ScrollToBottomButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { chevronDown } from "../utils/icons"

export interface ScrollToBottomButtonProps {
isAtBottom: boolean
scrollToBottom: () => void
streaming: boolean
}

export function ScrollToBottomButton({
isAtBottom,
scrollToBottom,
streaming,
}: ScrollToBottomButtonProps) {
if (isAtBottom) return null

const className = streaming
? "shiny-chat-scroll-to-bottom streaming"
: "shiny-chat-scroll-to-bottom"

return (
<button
type="button"
className={className}
title="Scroll to bottom"
aria-label="Scroll to bottom"
onClick={() => scrollToBottom()}
>
<span dangerouslySetInnerHTML={{ __html: chevronDown }} />
</button>
)
}
108 changes: 102 additions & 6 deletions js/src/chat/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,35 @@ shiny-chat-container {
}
}

// Wrapper positions the scroll-to-bottom button over the messages area
.shiny-chat-messages-wrapper {
position: relative;
min-height: 0; // Allow grid child to shrink
overflow: clip; // Clip the absolutely-positioned button without creating a scroll container
}

// Scroll container — useStickToBottom attaches scrollRef here
.shiny-chat-messages {
display: flex;
flex-direction: column;
gap: 2rem;
height: 100%;
overflow: auto;
margin-bottom: 1rem;

// Make space for the scroll bar
--_scroll-margin: 1rem;
padding-right: var(--_scroll-margin);
margin-right: calc(-1 * var(--_scroll-margin));
}

// Content wrapper — useStickToBottom attaches contentRef here
.shiny-chat-messages-content {
--shiny-chat-messages-padding-bottom: 4rem;

display: flex;
flex-direction: column;
gap: 2rem;
// Extra breathing room so the last message isn't flush against the input
padding-bottom: var(--shiny-chat-messages-padding-bottom);
}

// React renders messages as <div class="shiny-chat-message"> (not custom elements),
// so we use class selectors here.
.shiny-chat-message {
Expand Down Expand Up @@ -149,8 +165,7 @@ shiny-chat-container {

margin-top: calc(-1 * var(--_input-padding-top));
position: sticky;
bottom: calc(-1 * var(--_input-padding-bottom) + 4px);
// 4px: autoscroll adds 2px to height, this keeps input from wiggling when scrolling on top of chat
bottom: calc(-1 * var(--_input-padding-bottom));
padding-block: var(--_input-padding-top) var(--_input-padding-bottom);

textarea {
Expand Down Expand Up @@ -189,6 +204,87 @@ shiny-chat-container {
display: none;
}

/* Scroll-to-bottom button — absolutely positioned within the messages wrapper */
.shiny-chat-scroll-to-bottom {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 1;

display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border: var(--shiny-chat-border);
border-radius: 50%;
background-color: var(--bs-body-bg, #fff);
color: var(--bs-body-color, #212529);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
transition:
background-color 0.15s ease,
box-shadow 0.15s ease;
line-height: 1;

&:hover {
background-color: var(--bs-tertiary-bg, #f8f9fa);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.16);
}

&:focus-visible {
outline: 2px solid var(--bs-primary, #007bc2);
outline-offset: 2px;
}

svg {
display: block;
}

// Spinning gradient border while streaming
&.streaming {
--_streaming-gradient: var(
--shiny-chat-streaming-color,
var(--bs-indigo, #4b00c1),
var(--bs-purple, #74149c),
var(--bs-pink, #bf007f)
);

&::before {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
background: linear-gradient(120deg, var(--_streaming-gradient), transparent);
animation: shiny-chat-spin 1.2s linear infinite;

// Mask out the center so only the 2px border ring is visible
-webkit-mask: radial-gradient(
farthest-side,
transparent calc(100% - 3px),
#000 calc(100% - 1px)
);
mask: radial-gradient(
farthest-side,
transparent calc(100% - 3px),
#000 calc(100% - 1px)
);
}
}
}

@keyframes shiny-chat-spin {
to {
transform: rotate(360deg);
}
}

@media (prefers-reduced-motion: reduce) {
.shiny-chat-scroll-to-bottom.streaming::before {
animation: none;
}
}

/* External link dialog styling */
.shinychat-external-link-dialog {
padding: 0;
Expand Down
2 changes: 2 additions & 0 deletions js/src/utils/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ export const plus = `<svg xmlns="http://www.w3.org/2000/svg" width="10px" height
export const fullscreenEnter = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="height:1em;width:1em;fill:currentColor;" aria-hidden="true" role="img"><path d="M20 5C20 4.4 19.6 4 19 4H13C12.4 4 12 3.6 12 3C12 2.4 12.4 2 13 2H21C21.6 2 22 2.4 22 3V11C22 11.6 21.6 12 21 12C20.4 12 20 11.6 20 11V5ZM4 19C4 19.6 4.4 20 5 20H11C11.6 20 12 20.4 12 21C12 21.6 11.6 22 11 22H3C2.4 22 2 21.6 2 21V13C2 12.4 2.4 12 3 12C3.6 12 4 12.4 4 13V19Z"/></svg>`

export const xLg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16"><path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/></svg>`

export const chevronDown = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16" aria-hidden="true"><path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/></svg>`
Loading
Loading