Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
3396452
feat(js): add thinking transport types and state management
gadenbuie May 4, 2026
596f645
feat(r): add thinking state management and topic extraction
gadenbuie May 4, 2026
cba42c8
feat(r): integrate thinking content into streaming pipeline
gadenbuie May 4, 2026
17267eb
feat(js): add ThinkingDisplay component with collapse/expand
gadenbuie May 4, 2026
cf1c948
feat(js): render ThinkingDisplay for thinking messages
gadenbuie May 4, 2026
b69326a
feat(py): add thinking transport types, topic extraction, and stream …
gadenbuie May 4, 2026
4925f3a
fix(js): treat durationMs of 0 as no timing data
gadenbuie May 4, 2026
687f23e
chore(pkg-r): Use dev ellmer
gadenbuie May 4, 2026
d4ecb5e
fix(js): move thinking styles into SCSS pipeline and polish component
gadenbuie May 4, 2026
48d2bf9
fix(r,py): defer chunk_start until first non-thinking content
gadenbuie May 4, 2026
79215d8
refactor: move thinking logic entirely client-side
gadenbuie May 4, 2026
353db78
feat(js): animate thinking panel and debounce topic display
gadenbuie May 4, 2026
1c5d08d
feat(js): crossfade thinking topic label on change
gadenbuie May 5, 2026
c927343
feat(js): render topic labels inline in thinking content
gadenbuie May 5, 2026
7ec3f14
refactor(js): nest thinking traces inside assistant messages
gadenbuie May 5, 2026
3619e04
refactor(js): replace segments+thinking with unified MessageBlock array
gadenbuie May 5, 2026
acaaead
fix(js): make thinking dots perfectly circular
gadenbuie May 5, 2026
91183f4
feat(js): replace three thinking dots with single pulsing dot
gadenbuie May 5, 2026
e641530
fix(js): flush topicBuffer on thinking block finalization
gadenbuie May 5, 2026
346f9e4
fix(js): add missing id to thinking header button for aria-labelledby
gadenbuie May 5, 2026
3ad150f
fix(py): preserve content_type_override in pending message queue
gadenbuie May 5, 2026
9c7132d
chore: sync JS dist assets to R and Python packages
gadenbuie May 5, 2026
f9e3fe2
fix(js): harden ThinkingDisplay accessibility and motion handling
gadenbuie May 5, 2026
520daf0
fix(js): finalize thinking blocks on remove_loading (disconnect resil…
gadenbuie May 5, 2026
e68ab2f
fix(js): cap expanded thinking content at 60vh with scroll
gadenbuie May 5, 2026
0febdaa
chore: sync JS dist assets to R and Python packages
gadenbuie May 5, 2026
477d9c6
test(js): add comprehensive thinking block reducer tests
gadenbuie May 5, 2026
6bc635f
fix(js): add focus-visible outline to thinking header button
gadenbuie May 5, 2026
ff8b989
chore: sync JS dist assets to R and Python packages
gadenbuie May 5, 2026
7902f10
fix(css): reduce thinking content max-height to 33dvh
gadenbuie May 5, 2026
7f4275c
feat: detect <thinking> tags in content for local model support and P…
gadenbuie May 5, 2026
c656d90
feat(js): stream thinking UI in real-time for local model <thinking> …
gadenbuie May 5, 2026
25721f4
docs(r): document thinking display in chat_ui API reference
gadenbuie May 5, 2026
ce20126
docs(py): document thinking display in Chat class docstring
gadenbuie May 5, 2026
dbd8661
fix(js): handle split closing </topic> tags and add inert to collapse…
gadenbuie May 6, 2026
f6f95d9
fix(js): hide empty thinking blocks and suppress flash on replay
gadenbuie May 6, 2026
a6fcf25
fix(js): engage stick-to-bottom when expanded thinking overflows
gadenbuie May 6, 2026
0ed8957
feat(context): add ChatScrollContext and useChatStopScroll hook
gadenbuie May 6, 2026
97c1b6c
chore(py): bump chatlas minimum to >=0.15.0
gadenbuie May 6, 2026
2fae0ec
chore: build and update assets
gadenbuie May 6, 2026
c5357d0
fix(js): skip thinking tag detection inside fenced code blocks
gadenbuie May 6, 2026
2e030cb
chore: build and update assets
gadenbuie May 6, 2026
e461570
`air format` (GitHub Actions)
gadenbuie May 6, 2026
a58bf13
`devtools::document()` (GitHub Actions)
gadenbuie May 6, 2026
1b2a514
refactor(py): route thinking chunks through _append_message_chunk
gadenbuie May 8, 2026
ebbf3d7
Merge remote-tracking branch 'origin/main' into ui/thinking
gadenbuie May 8, 2026
8f09b48
`usethis::use_tidy_description()` (GitHub Actions)
gadenbuie May 8, 2026
af8982f
fix(py): avoid sending thinking tokens to client twice
gadenbuie May 8, 2026
9978fa0
refactor(py): make _is_content_thinking a TypeGuard
gadenbuie May 11, 2026
b051abd
fix(js): keep thinking block streaming flag in sync with message
gadenbuie May 11, 2026
7edfd90
test(py): add on_destroy stub to _MockSession
gadenbuie May 11, 2026
31eee63
refactor(py): use chatlas 0.17.0 ContentThinkingDelta for stream thin…
cpsievert May 11, 2026
e0fbc2d
chore: rebuild and copy JS assets to R and Python packages
cpsievert May 11, 2026
423ac42
docs: update changelogs for thinking display feature (#208)
gadenbuie May 11, 2026
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.

94 changes: 53 additions & 41 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.

23 changes: 13 additions & 10 deletions js/src/chat/ChatContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MessageErrorBoundary } from "./MessageErrorBoundary"
import { ChatInput, type ChatInputHandle } from "./ChatInput"
import { ScrollToBottomButton } from "./ScrollToBottomButton"
import { ExternalLinkDialogComponent } from "./ExternalLinkDialog"
import { ChatScrollContext } from "./context"
import type { ChatMessageData } from "./state"
import type { ChatTransport } from "../transport/types"

Expand Down Expand Up @@ -55,7 +56,7 @@ export const ChatContainer = forwardRef<
const pendingUrlRef = useRef<string | null>(null)
pendingUrlRef.current = pendingUrl

const { scrollRef, contentRef, isAtBottom, scrollToBottom } =
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } =
useStickToBottom({ resize: "smooth" })

useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -176,15 +177,17 @@ export const ChatContainer = forwardRef<
onClick={onMessagesClick}
onKeyDown={onSuggestionKeydown}
>
<ChatMessages messages={messages} iconAssistant={iconAssistant} />
{streamingMessage && (
<MessageErrorBoundary key={streamingMessage.id}>
<ChatMessage
message={streamingMessage}
iconAssistant={iconAssistant}
/>
</MessageErrorBoundary>
)}
<ChatScrollContext.Provider value={stopScroll}>
<ChatMessages messages={messages} iconAssistant={iconAssistant} />
{streamingMessage && (
<MessageErrorBoundary key={streamingMessage.id}>
<ChatMessage
message={streamingMessage}
iconAssistant={iconAssistant}
/>
</MessageErrorBoundary>
)}
</ChatScrollContext.Provider>
</div>
</div>
<ScrollToBottomButton
Expand Down
31 changes: 20 additions & 11 deletions js/src/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo } from "react"
import type { ChatMessageData } from "./state"
import { MarkdownContent } from "../markdown/MarkdownContent"
import { ThinkingDisplay } from "./ThinkingDisplay"
import { robot, dots_fade } from "../utils/icons"
import { chatTagToComponentMap } from "./chatTagToComponentMap"

Expand All @@ -14,21 +15,19 @@ export const ChatMessage = memo(function ChatMessage({
iconAssistant,
}: ChatMessageProps) {
const isUser = message.role === "user"
const isEmpty = message.content.trim() === ""
const hasContent =
message.content.trim() !== "" ||
message.blocks.some((b) => b.type === "thinking")

let iconHtml: string | undefined
if (isUser) {
iconHtml = message.icon || undefined
} else {
iconHtml = isEmpty ? dots_fade : (message.icon ?? iconAssistant ?? robot)
iconHtml = hasContent ? (message.icon ?? iconAssistant ?? robot) : dots_fade
}

const roleClass = isUser ? "shiny-chat-user-message" : "shiny-chat-message"

const segments = message.segments ?? [
{ content: message.content, contentType: message.contentType },
]

return (
<div className={roleClass}>
{iconHtml && (
Expand All @@ -38,18 +37,28 @@ export const ChatMessage = memo(function ChatMessage({
/>
)}
<div className="shiny-chat-message-content">
{segments.map((seg, i, arr) => {
{message.blocks.map((block, i) => {
if (block.type === "thinking") {
return (
<ThinkingDisplay
key={i}
thinking={block}
messageId={`${message.id}-${i}`}
/>
)
}
const isLast = i === message.blocks.length - 1
const el = (
<MarkdownContent
key={i}
content={seg.content}
contentType={seg.contentType}
content={block.content}
contentType={block.contentType}
role={message.role}
streaming={message.streaming && i === arr.length - 1}
streaming={message.streaming && isLast}
tagToComponentMap={chatTagToComponentMap}
/>
)
if (seg.contentType === "text") {
if (block.contentType === "text") {
return (
<div key={i} className="content-type-text">
{el}
Expand Down
272 changes: 272 additions & 0 deletions js/src/chat/ThinkingDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import {
useState,
useEffect,
useRef,
memo,
useCallback,
useLayoutEffect,
} from "react"
import { useStickToBottom } from "use-stick-to-bottom"
import type { ThinkingBlock } from "./state"
import { MarkdownContent } from "../markdown/MarkdownContent"
import { chatTagToComponentMap } from "./chatTagToComponentMap"
import { useChatStopScroll } from "./context"

interface ThinkingDisplayProps {
thinking: ThinkingBlock
messageId: string
}

const TOPIC_MIN_DISPLAY_MS = 2500

function useDisplayedTopic(topic: string | null | undefined): string | null {
const [displayed, setDisplayed] = useState<string | null>(null)
const lastSetAt = useRef(0)
const pendingTopic = useRef<string | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
if (!topic) return

const now = Date.now()
const elapsed = now - lastSetAt.current

if (elapsed >= TOPIC_MIN_DISPLAY_MS || !displayed) {
setDisplayed(topic)
lastSetAt.current = now
pendingTopic.current = null
} else {
pendingTopic.current = topic
if (!timerRef.current) {
const remaining = TOPIC_MIN_DISPLAY_MS - elapsed
timerRef.current = setTimeout(() => {
timerRef.current = null
if (pendingTopic.current) {
setDisplayed(pendingTopic.current)
lastSetAt.current = Date.now()
pendingTopic.current = null
}
}, remaining)
}
}

return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [topic, displayed])

return displayed
}

const FADE_DURATION_MS = 200

function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
)
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
const handler = (e: MediaQueryListEvent) => setReduced(e.matches)
mql.addEventListener("change", handler)
return () => mql.removeEventListener("change", handler)
}, [])
return reduced
}

function useFadingText(text: string): { visible: string; fading: boolean } {
const reducedMotion = usePrefersReducedMotion()
const [visible, setVisible] = useState(text)
const [fading, setFading] = useState(false)
const pendingText = useRef(text)

useLayoutEffect(() => {
if (text === visible) return
pendingText.current = text

if (reducedMotion) {
setVisible(text)
setFading(false)
return
}

setFading(true)
const timer = setTimeout(() => {
setVisible(pendingText.current)
setFading(false)
}, FADE_DURATION_MS)

return () => clearTimeout(timer)
}, [text, visible, reducedMotion])

return { visible, fading }
}

function ChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className="shinychat-thinking-chevron"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
{...(expanded ? { "data-expanded": "" } : {})}
>
<path
d="M4.5 2.5L8 6L4.5 9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

export const ThinkingDisplay = memo(function ThinkingDisplay({
thinking,
messageId,
}: ThinkingDisplayProps) {
const [expanded, setExpanded] = useState(false)
const [userToggled, setUserToggled] = useState(false)
const prevStreamingRef = useRef(thinking.streaming)
const outerStopScroll = useChatStopScroll()

const {
scrollRef: innerScrollRef,
contentRef: innerContentRef,
scrollToBottom: innerScrollToBottom,
} = useStickToBottom({ resize: "smooth" })

// When the inner container transitions from non-overflowing to overflowing,
// kick stick-to-bottom into gear so it follows the stream.
const wasOverflowing = useRef(false)
useEffect(() => {
const el = innerScrollRef.current
if (!el || !expanded || !thinking.streaming) return
const isOverflowing = el.scrollHeight > el.clientHeight
if (isOverflowing && !wasOverflowing.current) {
innerScrollToBottom()
}
wasOverflowing.current = isOverflowing
}, [
thinking.content,
expanded,
thinking.streaming,
innerScrollToBottom,
innerScrollRef,
])

const displayedTopic = useDisplayedTopic(
thinking.streaming ? thinking.topic : null,
)

// Auto-collapse when thinking completes (unless user has re-expanded after)
useEffect(() => {
if (prevStreamingRef.current && !thinking.streaming) {
if (!userToggled) {
const timer = setTimeout(() => setExpanded(false), 600)
return () => clearTimeout(timer)
}
prevStreamingRef.current = thinking.streaming
}
prevStreamingRef.current = thinking.streaming
}, [thinking.streaming, userToggled])

const handleToggle = useCallback(() => {
setExpanded((prev) => {
if (prev) {
outerStopScroll?.()
} else if (thinking.streaming) {
wasOverflowing.current = false
innerScrollToBottom()
}
return !prev
})
if (!thinking.streaming) {
setUserToggled(true)
}
}, [thinking.streaming, outerStopScroll, innerScrollToBottom])

const headerText = getHeaderText(thinking, displayedTopic)
const { visible: labelText, fading: labelFading } = useFadingText(headerText)

if (!thinking.streaming && !thinking.content.trim()) {
return null
}

return (
<div
className="shinychat-thinking"
data-streaming={thinking.streaming || undefined}
>
<button
id={`thinking-header-${messageId}`}
className="shinychat-thinking-header"
onClick={handleToggle}
aria-expanded={expanded}
aria-controls={`thinking-content-${messageId}`}
>
<ChevronIcon expanded={expanded} />
<span
className="shinychat-thinking-label"
data-fading={labelFading || undefined}
>
{labelText}
</span>
{thinking.streaming && (
<svg
className="shinychat-thinking-dot"
width="8"
height="8"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle cx="4" cy="4" r="4" />
</svg>
)}
</button>
<div
className="shinychat-thinking-content"
id={`thinking-content-${messageId}`}
role="region"
aria-labelledby={`thinking-header-${messageId}`}
aria-hidden={!expanded ? "true" : undefined}
inert={!expanded || undefined}
data-expanded={expanded || undefined}
>
<div className="shinychat-thinking-content-inner" ref={innerScrollRef}>
<div ref={innerContentRef}>
<MarkdownContent
content={thinking.content}
contentType="markdown"
role="assistant"
streaming={thinking.streaming}
tagToComponentMap={chatTagToComponentMap}
/>
</div>
</div>
</div>
</div>
)
})

function getHeaderText(
thinking: ThinkingBlock,
displayedTopic: string | null,
): string {
if (thinking.streaming) {
return displayedTopic ?? "Thinking"
}
if (thinking.durationMs != null && thinking.durationMs >= 500) {
const seconds = Math.round(thinking.durationMs / 1000)
if (seconds < 1) return "Thought for less than a second"
return `Thought for ${seconds}s`
}
return "Thinking"
}
1 change: 1 addition & 0 deletions js/src/chat/chat-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function parseInitialMessages(container: HTMLElement): ChatMessageData[] {
contentType,
streaming: false,
icon,
blocks: [{ type: "content", content, contentType }],
})
})

Expand Down
Loading
Loading