feat: greeting messages — first-class welcome UI for chat#223
feat: greeting messages — first-class welcome UI for chat#223gadenbuie wants to merge 49 commits into
Conversation
Adds GreetingOptions + five greeting ChatAction members, a GreetingData slice on ChatState with reducer cases (greeting, greeting_start, greeting_chunk, greeting_end, greeting_clear) plus auto-dismiss in INPUT_SENT/message/chunk_start and re-show in clear. Introduces the ChatGreeting React component (stand-alone wrapper, MarkdownContent rendering, fade-in/fade-out animations, prefers-reduced-motion), the greeting SCSS partial, ChatContainer integration as first child of .shiny-chat-messages-content, and chat-entry/ChatApp wiring of the static greeting attribute. Refs #217
Introduces chat_greeting() data wrapper with content + dismissible / as_assistant_message / include_in_history options (validating the dismissible+as_assistant constraint) and adds the greeting parameter to chat_ui(). Plain strings, HTML(), and htmltools tags/tagList are auto-wrapped; tag dependencies are collected and attached to the container. include_in_history is kept server-only and never appears in the JSON wire payload. Refs #217
Adds the ChatGreeting class and chat_greeting() factory following the ChatMessage pattern, accepting str / HTML / Tag / TagList / AsyncIterator[str] content and the same option set as the R wrapper. chat_ui() (and ChatExpress.ui()) gain a greeting parameter that auto- wraps non-ChatGreeting inputs, blocks async iterators (pointing users to Chat.set_greeting()), collects htmltools dependencies, and serializes the wire payload onto the <shiny-chat-container> attribute. include_in_history stays server-only. Refs #217
Server entry point for sending greeting custom messages. Handles NULL (greeting_clear), auto-wraps non-chat_greeting input, delegates to chat_append() when as_assistant_message=TRUE, sends a single greeting action for static string/HTML/tag content (collecting HTML deps via process_ui), and streams greeting_start / greeting_chunk / greeting_end for generator/promise content. include_in_history is stripped from all wire payloads. Refs #217
Async server entry point mirroring the R chat_set_greeting() shape. None sends greeting_clear; non-ChatGreeting input is auto-wrapped; as_assistant_message delegates to append_message / append_message_stream; static content sends a single greeting action with HTML deps; async iterator content streams greeting_start / greeting_chunk / greeting_end. Adds matching TypedDicts (GreetingOptions and the five greeting ChatAction variants) to _chat_types.py. include_in_history stays server-only. Refs #217
JS: 29 reducer tests for the five greeting actions plus auto-dismiss on INPUT_SENT/message/chunk_start and re-show on clear; 4 component tests for ChatGreeting visibility, data-dismissing transition, and prefers-reduced-motion fast path. R: 21 testthat tests covering chat_greeting() validation, chat_ui(greeting=) attribute serialization and HTML deps, and chat_set_greeting() envelope across NULL, static string, HTML, as_assistant_message delegation, and generator streaming. Python: 19 pytest tests mirroring the R suite for ChatGreeting, chat_ui(greeting=), and Chat.set_greeting(). Refs #217
Centered text breaks reading flow for code blocks, tables, and lists that authors will naturally include in a greeting. Drop text-align: center and bump --shiny-chat-greeting-max-width from 540px to 680px so a row of three 175px suggestion cards (with 0.75rem gaps) fits comfortably inside the greeting's 1.5rem padding. Refs #217
Without width: 100%, the wrapper sizes to its content and only caps at max-width, so streaming chunks that widen the content (e.g. a code block or table appearing partway through) cause the greeting to shift horizontally. Pin width to 100% so it claims its full slot up to --shiny-chat-greeting-max-width from the first render. Refs #217
Measure the greeting's offsetHeight at the moment dismiss starts and stash it on the element as --_dismiss-height, then animate height from that value down to 0 alongside opacity. Also pulls margin-bottom to -2rem (matching the parent flex gap) so the next message slides up into the freed space instead of dropping abruptly after the element unmounts. Animation bumped to 250ms to give the collapse a perceptible arc; overflow hidden during dismiss clips content as it shrinks. Refs #217
The reducer would set greeting.visible=false in one commit, but the component's local dismissing flag was promoted in a follow-up useEffect. Between the two paints, the render check (!visible && !dismissing) returned null — the greeting briefly vanished, the message list reflowed up to fill the space, then the greeting came back with data-dismissing to animate from full height. Visually the greeting "reappeared" before collapsing. Detect the visible: true→false transition synchronously during render via a derived-prev-state pattern so dismissing flips in the same commit as visible=false. The captured rendered height is now stored from the last visible-and-not-dismissing layout effect and applied as an inline --_dismiss-height in the same render that flips data-dismissing, so the animation interpolates from the real height without needing an extra DOM measurement after the dismiss flag is set. Refs #217
Once the user submits, the new user message and assistant placeholder both pop into the DOM. If the greeting starts collapsing in the same beat, too many elements move at once and the eye can't track any of them. Introduce a 350ms "pending dismiss" hold: visible=false flips the greeting into a holding state where it stays at full height, then data-dismissing is applied (and the height collapse runs) after the delay. Reduced motion still removes immediately. Refs #217
Flip the dismiss ordering: instead of holding the greeting open while the user / assistant messages settle into the DOM, hide the new messages until the greeting has finished collapsing. The reducer still adds them immediately (the network side keeps working), but a data-greeting-dismissing attribute on .shiny-chat-messages-content suppresses any child that isn't the greeting itself for the duration of the exit animation. When the greeting unmounts, the attribute clears and the messages appear in their final position. Lifts the dismiss lifecycle onto greeting state via a new greeting.dismissing flag (set by the reducer on the same transition that flips visible=false; cleared by a new greeting_dismissed UI action that ChatGreeting dispatches on animationend, or immediately when prefers-reduced-motion is set). Refs #217
Drop the dismissed-guard short-circuit on the greeting, greeting_start, and greeting_chunk reducer cases. A dismissed greeting now absorbs new content silently — visible stays false, dismissed stays true — and the fresh content surfaces the next time the user clears the chat. This matches the natural author flow: set a dynamic greeting on every server tick (or on profile load) without having to first clear via chat_set_greeting(id, NULL). Refs #217
Greetings are now a self-contained feature with their own lifecycle (auto-dismiss, re-show on clear, replace-while-dismissed, server-only clear). Authors who want an assistant-styled welcome can call chat_append() directly — the de facto pattern that pre-dated this feature — so the as_assistant_message flag was only adding a half- implemented mode without any new capability. Removes the parameter from chat_greeting(), the dismissible+as_assistant validation, the field from the S3 list, and the delegation branch in chat_set_greeting() that routed to chat_append(). chat_set_greeting() now always sends greeting-protocol actions. Drops the two tests that exercised the removed branches. Refs #217
Mirrors the R change: greetings are self-contained, so the as_assistant_message flag is removed from ChatGreeting / chat_greeting() and from Chat.set_greeting()'s delegation branch. The method now always routes through the greeting-action path. Authors who want an assistant-styled welcome can call Chat.append_message() / Chat.append_message_stream() directly. Refs #217
Gate the reveal keyframe on a shiny-chat-greeting--enter class that is applied for the first paint of each ChatGreeting instance and removed after the reveal's animationend (or immediately under reduced motion). A chat_set_greeting() that replaces a still-visible greeting keeps the same wrapper mounted, so the new content swaps in without any entrance animation. Clear → re-show and the regenerate pattern unmount the wrapper, so the next mount re-runs the entrance animation as expected. Also scope the dismiss animationend listener to its own animation name so a stray reveal-end event can't fire greeting_dismissed. Refs #217
useStickToBottom watches contentRef's size and auto-scrolls when it grows. With the greeting rendered inside contentRef, a streaming greeting that overflowed the viewport would yank the scroll to its bottom — pulling the start of the welcome out of the reader's view. Move <ChatGreeting> outside the contentRef wrapper (still inside the scroll container) so only message growth participates in stick-to- bottom. Make the scroll container a flex column so a stand-alone greeting can margin-block:auto into the vertical center when no messages exist, and simplify the dismiss-hide and padding selectors now that greeting is no longer a child of .shiny-chat-messages-content. Refs #217
The flag was stored on chat_greeting() / ChatGreeting but never read by anything — the option was always stripped from the wire payload and there is no server-side history slot to push greeting content into. shinychat doesn't manage model history (authors do), so wiring this is a feature in its own right and out of scope for the greeting work. Same reasoning as the recent as_assistant_message drop: keep greetings a self-contained UI feature and avoid shipping half-implemented modes that promise behavior they don't deliver. Drops the param from R's chat_greeting() and Python's ChatGreeting/chat_greeting(), regenerates the R man page, and prunes the no-longer-relevant test assertions. Refs #217
Adds a greeting argument to chat_mod_server() for the common case where the author wants to seed a one-shot static or pre-computed greeting at module initialization, and a namespace-aware set_greeting() helper on the returned list for everything else (replacing the greeting, streaming via a generator, or clearing it from any reactive context). The returned helper closes over the module's session/namespace so callers do not have to reach into the chat_mod_ui()'s internal NS() wiring. Refs #217
When the greeting was moved out of .shiny-chat-messages-content, the click / focus / keydown handlers stayed on that inner div — so suggestion clicks inside the greeting no longer bubbled to a handler. Move those event handlers up to .shiny-chat-messages (the scroll container that contains both the greeting and the messages-content div); clicks from either subtree now reach the handlers. Drop the height-collapse half of the dismiss animation in favor of a plain opacity fade. The height interpolation no longer worked cleanly once the greeting's vertical centering used margin-block:auto from the flex scroll container — the auto margins keep absorbing space as height shrinks. The data-greeting-dismissing attribute on .shiny-chat-messages-content still hides newly-arrived messages during the fade, so the visual sequence is: fade out → unmount → messages appear. Removes the now-unused inline --_dismiss-height style, the useLayoutEffect that captured offsetHeight, and the margin-bottom hack that cancelled the parent flex gap. Refs #217
INPUT_SENT adds the user message and assistant placeholder to messages-content synchronously, even though they're CSS-hidden via [data-greeting-dismissing] for the duration of the fade. That flipped messages-content from :empty to "has children" — and with it the :has() selector that gave the greeting margin-block:auto — so the greeting visibly jumped from centered to top-aligned the instant INPUT_SENT landed, then faded from its new position. Extend the centering and padding-bottom selectors to treat [data-greeting-dismissing] as equivalent to :empty. The greeting now holds whatever position it had until the fade completes; only after animationend (and the resulting greeting_dismissed reducer action) does the attribute clear, the messages become visible, and the layout settle into its new shape. A visible greeting can never coexist with pre-existing visible messages (auto-dismiss fires synchronously without animation on the "greeting + initial messages" path), so this rule is unambiguous. Refs #217
Derive the at-bottom signal from the scroll container itself rather than useStickToBottom's contentRef-based isAtBottom. The greeting lives outside contentRef (so its growth does not engage stick-to- bottom), which meant a tall standalone greeting could overflow the scroll area without ever revealing the scroll-to-bottom button or the input's top shadow. The button's streaming style also tracks greeting.streaming now.
Sends "{id}_greeting_requested" with value "init" on first paint and
"cleared" after each chat_clear(), deferred by IntersectionObserver
until the chat element is visible in the viewport.
… chat_clear
Replace the lifecycle-event model ("init"/"cleared") with a
state-driven signal that fires only when the chat is visible, empty,
and has no greeting. This avoids wasted LLM greeting calls on
bookmark restore, where the old "init" fired before restored messages
arrived.
chat_clear() gains a `greeting` parameter (R: greeting = FALSE,
Python: greeting: bool = False). When TRUE, the greeting is also
cleared, causing greeting_requested to fire again naturally.
The `greeting` parameter now accepts a function in addition to static content. When a function is provided, the module sets up an internal observer on `greeting_requested` that calls it on init and after `clear(greeting = TRUE)`. Zero-arg functions are called as-is; one-arg functions receive a cloned client with empty turns.
Covers greeting appearance, suggestion cards, dismiss on message, re-show after clear, and regeneration after clear+greeting.
… greeting test app
CI installs shiny>=1.4.0 which predates ui.toolbar. Feature-detect and use input_action_button as fallback.
- Add `greeting` field to ClearAction TypedDict - Type clear_messages action as ClearAction instead of dict[str, object] - Add isinstance guards for content type narrowing in tests - Add pyright: ignore for hasattr-guarded ui.toolbar calls - Remove mock-session tests (covered by Playwright e2e tests)
- chat_greeting(): add Patterns section (non-dismissible, suggestions, tags) - chat_ui(): add Greeting section explaining greeting_requested input - chat_set_greeting(): add LLM-generated and regenerate pattern examples - chat_clear(): add description and regenerate example - chat_mod_server(): add Greeting section with static/zero-arg/one-arg modes
- chat_greeting(): add examples (non-dismissible, suggestions) and See Also - chat_ui(): document greeting_requested input in greeting param - Chat.set_greeting(): add Notes on greeting_requested, LLM and regenerate examples - Chat.clear_messages(): expand greeting param with regenerate pattern - quartodoc: add chat_greeting to API reference
- Merge greeting_requested into main changelog bullet (both packages) - Move Greeting section before params in chat_ui() roxygen - Reorder chat_mod_server() greeting modes: one-arg first, zero-arg second - Lead with shared behavior before mode-specific details - Fix "first load" → "first viewed on the page and empty" - Collapse character vectors in chat_greeting() for convenience
ChatGreeting was special-casing HTML() to skip split_html_islands(), unlike ChatMessage which routes all non-string content through it.
…elpers Deduplicate three identical greeting dismiss blocks in the reducer (INPUT_SENT, message, chunk_start) into a shared dismissGreeting() helper. Extract the shared visibility computation from greeting and greeting_start cases into computeGreetingVisibility(). Add clarifying comment on the clear case ternary.
Move the duplicated hook from ChatGreeting.tsx and ThinkingDisplay.tsx into its own module so both components import from a single source.
Export InitialGreeting from ChatApp.tsx and import it in chat-entry.ts instead of defining the same interface in both files.
…etection - Wrap greeting streaming in ExtendedTask so the session stays responsive - Switch greeting function detection from arity to named-argument matching - Add guard in chat_set_greeting() when a function is passed as content
The greeting function argument detection checks for `client` by name, but three tests used `greeter`, causing the argument to never be passed.
|
I noticed that I think that makes sense as a design choice, but I could see someone calling |
| export interface GreetingData { | ||
| content: string | ||
| contentType: ContentType | ||
| streaming: boolean | ||
| visible: boolean | ||
| dismissed: boolean | ||
| dismissing: boolean | ||
| options: GreetingOptions | ||
| blocks: ContentBlock[] | ||
| } |
There was a problem hiding this comment.
The dismiss lifecycle uses three correlated booleans (visible, dismissed, dismissing) that have 8 possible combinations, most of which are invalid — the reducer has to be careful not to produce them. Would a single status: "visible" | "dismissing" | "dismissed" enum work here? It would make impossible states unrepresentable while keeping the same animation coordination. The layout-stability trick with [data-greeting-dismissing] and the centering CSS is clever — not suggesting changing any of that.
| if (is.character(msg)) { | ||
| ui <- list(html = msg, deps = NULL) | ||
| chunk_content_type <- "markdown" | ||
| } else { | ||
| ui <- process_ui(pre_process_ui(msg), session) | ||
| chunk_content_type <- "html" | ||
| } |
There was a problem hiding this comment.
I think this means that HTML() will get treated like markdown.
| ): | ||
| self.dismissible = dismissible | ||
|
|
||
| if isinstance(content, AsyncIterator): |
There was a problem hiding this comment.
I think this check is too narrow. In Python, a value can be a valid async stream for async for without being an AsyncIterator specifically. If that happens here, we will fall through into the non-stream path instead.
I think the fix here should be:
- widen the types from
AsyncIteratortoAsyncIterable - update the runtime check accordingly, not just the annotation
- add a test with a custom async-iterable object, not just a native async generator
I would keep this scoped to async iterables only for this PR. Supporting sync iterables would be a separate feature expansion.
Closes #217
Summary
Adds native greeting messages as a first-class feature in shinychat. Greetings are a distinct UI element — separate from the conversation message stream — that welcome users when the chat loads and can include suggestion cards.
Screen.Recording.2026-05-15.at.8.42.05.AM.mov
Content paths
chat_ui(greeting = "## Welcome!")— renders immediately on mountchat_set_greeting()/Chat.set_greeting()with computed or streamed content<id>_greeting_requestedand streams an LLM-generated greetingKey design decisions
greeting_requestedinput: a state-driven Shiny input that fires when the chat is visible, empty, and has no greeting — handles init, clear, bookmark restore, and hidden-tab cases uniformlychat_clear(greeting=TRUE): clears both messages and greeting, naturally triggeringgreeting_requestedfor regenerationchat_mod_server(greeting=)accepts a static value or a function; functions are called automatically on init and afterclear(greeting=TRUE), with optional client injection for one-arg functionsProtocol
Five new action types for the stand-alone greeting path:
greeting,greeting_start,greeting_chunk,greeting_end,greeting_clear. Content flows through the same HAST markdown pipeline as regular messages.What was dropped during implementation
as_assistant_message— removed; authors who want assistant-styled welcomes usechat_append()directlyinclude_in_history— removed; shinychat doesn't own model historyVerification
Set
ANTHROPIC_API_KEYand run any of the example apps below. Things to verify:haiku_app.R— R with manual chat plumbinghaiku_mod_app.R— R withchat_mod_server()haiku_app.py— Python