diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts index ff34874..0f5ae14 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts @@ -69,6 +69,15 @@ export interface ChatLayoutProps extends BaseProps { * @default 'balanced' */ density?: Density; + /** + * Whether the layout's own auto-follow runs. Forwards `useChatStreamScroll`'s + * published `enabled` option, for hosts that position the transcript + * themselves; off, the layout installs no scroll listeners and never writes + * `scrollTop`, so the host is the only writer. + * + * @default true + */ + autoScroll?: boolean; } export declare function ChatLayout({ children, composer, density, emptyState, scrollButton, scrollRef: externalScrollRef, xstyle, className, style, 'data-testid': testId, ref, ...rest }: ChatLayoutProps): import("react").JSX.Element; export declare namespace ChatLayout { diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js index ff9b9fa..6d4df27 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js @@ -28,7 +28,7 @@ * - /apps/storybook/stories/ChatLayout.stories.tsx * - /packages/cli/assets/templates/blocks/components/ChatLayout/ (block examples) */ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import * as stylex from '@stylexjs/stylex'; import "../theme/tokens.stylex.js"; import { spacingVars } from "../theme/tokens.stylex.js"; @@ -195,6 +195,7 @@ export function ChatLayout({ className, style, 'data-testid': testId, + autoScroll = true, ref, ...rest }) { @@ -205,12 +206,21 @@ export function ChatLayout({ // --- Default scroll behavior --- const scroll = useChatStreamScroll({ - scrollRef: scrollContainerRef + scrollRef: scrollContainerRef, + // maka: forward the hook's own published switch. Off, it installs no + // listeners and moves nothing, which is what a host needs when the host is + // the one positioning the transcript. + enabled: autoScroll }); const newMsgs = useChatNewMessages({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked }); + useEffect(() => { + if (scroll.isLocked) { + newMsgs.dismiss(); + } + }, [scroll.isLocked, newMsgs.dismiss]); const defaultScrollButton = /*#__PURE__*/_jsx(ChatLayoutScrollButton, { isVisible: scroll.isScrolledUp || newMsgs.hasNewMessages, label: newMsgs.hasNewMessages ? t('@astryx.chatLayout.newMessages') : undefined, diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js b/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js index 889970f..459e6e8 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js @@ -122,6 +122,7 @@ function CallRow({ const row = /*#__PURE__*/_jsxs("div", { role: hasDetail ? 'button' : undefined, tabIndex: hasDetail ? 0 : undefined, + "data-slot": "chat-tool-call-row", "aria-expanded": hasDetail ? isDetailOpen : undefined, "aria-controls": hasDetail && isDetailOpen ? detailId : undefined, onClick: toggleDetail, diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js b/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js index 8c509bc..5f46785 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js +++ b/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js @@ -49,6 +49,8 @@ export function useChatNewMessages({ const elementRef = useRef(null); const cleanupRef = useRef(null); const attach = useCallback(el => { + lastMessageRef.current = null; + setHasNewMessages(false); observeResize(el, () => { onResizeRef.current?.(); const messages = el.getElementsByClassName('astryx-chat-message'); diff --git a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js index e1d2ebd..1f8337e 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js +++ b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js @@ -47,6 +47,45 @@ const styles = { // Helpers // ============================================================================= +const blockDisplays = new Set(['block', 'flex', 'flow-root', 'grid', 'list-item', 'table']); +function createsLineBoundary(node) { + return node instanceof HTMLBRElement || node instanceof HTMLElement && blockDisplays.has(window.getComputedStyle(node).display); +} +function getTrailingContextCharacter(node) { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent ?? '').slice(-1); + } + if (createsLineBoundary(node)) { + return '\n'; + } + for (let child = node.lastChild; child; child = child.previousSibling) { + const character = getTrailingContextCharacter(child); + if (character) { + return character; + } + } + return ''; +} +function getCharacterBeforeNode(editable, node) { + let current = node; + while (current && current !== editable) { + for (let sibling = current.previousSibling; sibling; sibling = sibling.previousSibling) { + const character = getTrailingContextCharacter(sibling); + if (character) { + return character; + } + } + const parent = current.parentNode; + if (!parent || parent === editable) { + return ''; + } + if (createsLineBoundary(parent)) { + return '\n'; + } + current = parent; + } + return ''; +} function getTextBeforeCursor(editable) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { @@ -61,7 +100,12 @@ function getTextBeforeCursor(editable) { } const node = range.startContainer; if (node.nodeType === Node.TEXT_NODE) { - return (node.textContent ?? '').slice(0, range.startOffset); + const nodeText = (node.textContent ?? '').slice(0, range.startOffset); + const prefix = getCharacterBeforeNode(editable, node); + return { + text: prefix + nodeText, + nodeStart: prefix.length + }; } return null; } @@ -306,15 +350,15 @@ export function useTriggerMenu(options) { if (!editable) { return; } - const textBefore = getTextBeforeCursor(editable); - if (textBefore === null) { + const cursorContext = getTextBeforeCursor(editable); + if (cursorContext === null) { if (state.isActive) { reset(); } return; } - const found = findActiveTrigger(textBefore, triggers); - if (!found) { + const found = findActiveTrigger(cursorContext.text, triggers); + if (!found || found.triggerStart < cursorContext.nodeStart) { if (state.isActive) { reset(); } @@ -322,9 +366,9 @@ export function useTriggerMenu(options) { } const { trigger, - query, - triggerStart + query } = found; + const triggerStart = found.triggerStart - cursorContext.nodeStart; if (!state.isActive || state.activeTrigger !== trigger) { triggerStartRef.current = triggerStart; setState(prev => ({ diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts index 82ae62c..a97eff5 100644 --- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts +++ b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts @@ -42,6 +42,12 @@ export interface DropdownMenuItemProps extends Pick * ``` */ -export declare function DropdownMenuItem({ icon, label, description, onClick, isDisabled, endContent, hasCloseOnSelect, variant, xstyle, className, style, }: DropdownMenuItemProps): import("react").JSX.Element; +export declare function DropdownMenuItem({ icon, 'aria-busy': ariaBusy, label, description, onClick, isDisabled, endContent, hasCloseOnSelect, variant, xstyle, className, style, }: DropdownMenuItemProps): import("react").JSX.Element; export declare namespace DropdownMenuItem { var displayName: string; } diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js index 446e25b..00eb8b3 100644 --- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js +++ b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js @@ -91,6 +91,7 @@ const itemSizeStyles = { */ export function DropdownMenuItem({ icon, + 'aria-busy': ariaBusy, label, description, onClick, @@ -117,6 +118,7 @@ export function DropdownMenuItem({ const isDestructive = variant === 'destructive'; return /*#__PURE__*/_jsx(Item, { role: "menuitem", + 'aria-busy': ariaBusy, tabIndex: isDisabled ? undefined : -1, onPointerMove: handlePointerMove, startContent: icon ? renderIconSlot(icon, { diff --git a/node_modules/@astryxdesign/core/dist/List/List.js b/node_modules/@astryxdesign/core/dist/List/List.js index 8753b5f..1fa8325 100644 --- a/node_modules/@astryxdesign/core/dist/List/List.js +++ b/node_modules/@astryxdesign/core/dist/List/List.js @@ -89,6 +89,7 @@ export function List({ className, style, 'data-testid': testId, + 'aria-label': ariaLabel, ref, ...props }) { @@ -106,7 +107,8 @@ export function List({ // is part of its contract and wins on conflict. , ...props, - "data-testid": testId + "data-testid": testId, + "aria-label": ariaLabel // Only when this component renders the header — writing `undefined` // unconditionally would erase a label a consumer pointed at their own // heading. @@ -151,4 +153,4 @@ export function List({ }) }); } -List.displayName = 'List'; +List.displayName = 'List'; diff --git a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts index 2ea4f3e..be99f2d 100644 --- a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts +++ b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts @@ -90,6 +90,10 @@ export interface MarkdownProps extends BaseProps { */ headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6; isStreaming?: boolean; + /** Source text the host has authoritatively marked as already presented. */ + settledText?: string; + /** Transform displayed source immediately before Markdown parsing. */ + transformSource?: (source: string) => string; onLinkClick?: (href: string, event: React.MouseEvent) => void | false; /** * Citation sources keyed by ID. When provided, `[id]` and `【id】` markers @@ -155,8 +159,8 @@ export interface MarkdownProps extends BaseProps { * * ``` */ -export declare function Markdown({ ref, children, display, density, headingLevelStart, isStreaming, onLinkClick, sources, citationStyle, contentWidth, contentAlign, components, inlinePlugins, autolink, xstyle, className, style, 'data-testid': testId, ...props }: MarkdownProps): React.ReactElement; +export declare function Markdown({ ref, children, display, density, headingLevelStart, isStreaming, settledText, transformSource, onLinkClick, sources, citationStyle, contentWidth, contentAlign, components, inlinePlugins, autolink, xstyle, className, style, 'data-testid': testId, ...props }: MarkdownProps): React.ReactElement; export declare namespace Markdown { var displayName: string; } -//# sourceMappingURL=Markdown.d.ts.map +//# sourceMappingURL=Markdown.d.ts.map diff --git a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js index 36ed303..745ba77 100644 --- a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js +++ b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js @@ -8,7 +8,7 @@ * @output Exports Markdown component and MarkdownProps * @position Core implementation; renders markdown as Astryx components */ -import { useMemo, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Fragment } from 'react'; import * as stylex from '@stylexjs/stylex'; import "../theme/tokens.stylex.js"; @@ -1053,6 +1053,8 @@ export function Markdown({ density = 'default', headingLevelStart = 1, isStreaming = false, + settledText, + transformSource, onLinkClick, sources, citationStyle = 'label', @@ -1078,7 +1080,10 @@ export function Markdown({ // Smooth bursty streamed chunks into a steady character-by-character reveal. // When not streaming, the hook returns children unchanged (no-op). - const smoothedText = useStreamingText(children, isStreaming); + const smoothedText = useStreamingText(children, isStreaming, { + settledText + }); + const parsedText = transformSource ? transformSource(smoothedText) : smoothedText; const incrementalStateRef = useRef(createIncrementalState()); // Reset incremental cache when the autolink option toggles — cached // settled blocks were parsed with the previous setting. @@ -1096,11 +1101,11 @@ export function Markdown({ incrementalStateRef.current = createIncrementalState(); return []; } - const input = trimStreamingArtifacts(smoothedText); + const input = trimStreamingArtifacts(parsedText); return parseMarkdownIncremental(input, incrementalStateRef.current, parseOptions); } - return parseMarkdown(children, parseOptions); - }, [display, smoothedText, children, isStreaming, parseOptions]); + return parseMarkdown(parsedText, parseOptions); + }, [display, parsedText, isStreaming, parseOptions]); // Assign each top-level heading the slug that parseOutlineFromMarkdown // would derive for it, so Outline hash links built from the same source @@ -1126,9 +1131,9 @@ export function Markdown({ if (display !== 'inline') { return []; } - const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children; + const input = isStreaming ? trimStreamingArtifacts(parsedText) : parsedText; return parseInline(input, parseOptions); - }, [display, smoothedText, children, isStreaming, parseOptions]); + }, [display, parsedText, isStreaming, parseOptions]); // Track recent boundaries for stacked fade-in animation. // The number of spans needed = ceil(animationDuration / tickInterval). @@ -1145,17 +1150,25 @@ export function Markdown({ const tickMs = tick != null ? Math.max(4, Math.round(tick / 10)) : 50; return Math.min(Math.ceil(duration / tickMs), 12); }, [token]); - const prevBlocksRef = useRef([]); - const prevInlineNodesRef = useRef([]); - const boundariesRef = useRef([]); - const smoothedLen = smoothedText.length; - const boundaries = useMemo(() => { - void smoothedLen; // dep trigger - const prevLen = display === 'inline' ? countInlineTextLength(prevInlineNodesRef.current) : countBlockTextLength(prevBlocksRef.current); - const next = computeBoundaries(boundariesRef.current, prevLen, maxSpans); - boundariesRef.current = next; - return next; - }, [display, smoothedLen, maxSpans]); + const visibleLength = display === 'inline' ? countInlineTextLength(inlineNodes) : countBlockTextLength(blocks); + const [fadeState, setFadeState] = useState(() => ({ + display, + settledText, + smoothedText, + visibleLength, + boundaries: settledText === undefined ? [0] : [visibleLength] + })); + const resetBoundaries = fadeState.display !== display || fadeState.settledText !== settledText || !smoothedText.startsWith(fadeState.smoothedText); + const boundaries = resetBoundaries ? [visibleLength] : fadeState.smoothedText === smoothedText ? fadeState.boundaries : computeBoundaries(fadeState.boundaries, fadeState.visibleLength, maxSpans); + if (fadeState.display !== display || fadeState.settledText !== settledText || fadeState.smoothedText !== smoothedText || fadeState.visibleLength !== visibleLength || fadeState.boundaries !== boundaries) { + setFadeState({ + display, + settledText, + smoothedText, + visibleLength, + boundaries + }); + } const cursor = { offset: 0, boundaries, @@ -1183,9 +1196,6 @@ export function Markdown({ children: inlineNodes.map((node, i) => renderInline(node, i, onLinkClick, cursor, citationCtx, LinkComponent, inlinePlugins, components)) }); - // Store current inline nodes for next render's boundary calculation. - prevInlineNodesRef.current = inlineNodes; - prevBlocksRef.current = []; return renderedInline; } const rendered = /*#__PURE__*/_jsx("div", { @@ -1202,11 +1212,6 @@ export function Markdown({ children: blocks.map((block, i) => renderBlock(block, i, blocks.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidth ? typeof contentWidth === 'number' ? `${contentWidth}px` : contentWidth : null, contentAlign, LinkComponent, inlinePlugins, components, t, headingIdMap)) }); - // Store current blocks for next render's boundary calculation. - // This ref write is safe under StrictMode: both invocations produce the same - // blocks (same smoothedText → same useMemo result), so both write the same value. - prevBlocksRef.current = blocks; - prevInlineNodesRef.current = []; return rendered; } -Markdown.displayName = 'Markdown'; +Markdown.displayName = 'Markdown'; diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts index 158ada1..b891fb9 100644 --- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts +++ b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts @@ -68,6 +68,12 @@ export interface SideNavItemProps extends BaseProps { * renders inside the primary link or button. */ endContent?: ReactNode; + /** + * Interactive trailing control rendered after the navigation control and + * before any nested items. Unlike `endContent`, this is a sibling rather + * than content inside the navigation control. + */ + trailingAction?: ReactNode; /** * Row-level secondary controls (icon buttons, menus) rendered as siblings * of the primary element at the trailing edge of the row — after the @@ -137,8 +143,8 @@ export interface SideNavItemProps extends BaseProps { * * ``` */ -export declare function SideNavItem({ as, label, icon, selectedIcon, isSelected, isDisabled, href, onClick, endContent, actions, children, collapsible: itemCollapsible, size, 'data-testid': testId, ref, xstyle, ...rest }: SideNavItemProps): import("react").JSX.Element | null; +export declare function SideNavItem({ as, label, icon, selectedIcon, isSelected, isDisabled, href, onClick, endContent, trailingAction, actions, children, collapsible: itemCollapsible, size, 'data-testid': testId, ref, xstyle, ...rest }: SideNavItemProps): import("react").JSX.Element | null; export declare namespace SideNavItem { var displayName: string; } -//# sourceMappingURL=SideNavItem.d.ts.map +//# sourceMappingURL=SideNavItem.d.ts.map diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js index 3d2cf00..e527e9d 100644 --- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js +++ b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js @@ -232,6 +232,7 @@ export function SideNavItem({ href, onClick, endContent, + trailingAction, actions, children, collapsible: itemCollapsible, @@ -547,7 +548,7 @@ export function SideNavItem({ const item = /*#__PURE__*/_jsxs("div", { ref: itemRef, ...stylex.props(styles.root, xstyle), - children: [itemElement, hasChildren && !isCollapsed && /*#__PURE__*/_jsx("div", { + children: [itemElement, trailingAction, hasChildren && !isCollapsed && /*#__PURE__*/_jsx("div", { id: `${id}-children`, role: "group", "aria-labelledby": `${id}-label`, @@ -575,4 +576,4 @@ export function SideNavItem({ }); return item; } -SideNavItem.displayName = 'SideNavItem'; +SideNavItem.displayName = 'SideNavItem'; diff --git a/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts b/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts index 7a35d1c..50ee70b 100644 --- a/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts +++ b/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts @@ -11,6 +11,8 @@ export interface UseStreamingTextOptions { * @default 'natural' */ speed?: StreamingTextSpeed; + /** Source text the host has authoritatively marked as already presented. */ + settledText?: string; } /** * Snap a UTF-16 code-unit offset down to the start of the grapheme cluster diff --git a/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.js b/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.js index 693d728..7d5724e 100644 --- a/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.js +++ b/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.js @@ -24,7 +24,7 @@ * - /packages/core/src/hooks/index.ts (exports) * - /packages/core/src/hooks/useStreamingText.test.ts (tests) */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useMediaQuery } from "./useMediaQuery.js"; import { useTheme } from "../theme/useTheme.js"; @@ -70,6 +70,44 @@ function isLowSurrogate(codeUnit) { return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; } +/** Longest shared prefix as a UTF-16 offset, without splitting a surrogate pair. */ +function commonPrefixLength(a, b) { + let offset = 0; + while (offset < a.length && offset < b.length) { + const aCodePoint = a.codePointAt(offset); + const bCodePoint = b.codePointAt(offset); + if (aCodePoint !== bCodePoint) break; + offset += aCodePoint !== undefined && aCodePoint > 0xffff ? 2 : 1; + } + return offset; +} + +/** + * Advances a UTF-16 offset without stopping between a surrogate pair. + * + * The rendered slice is snapped *back* to a grapheme boundary + * (snapToGraphemeBoundary), so a tick landing inside a pair would otherwise + * hold the code point back a frame; advancing past it reveals the glyph on + * the tick that reached it. + */ +function advanceTextOffset(text, offset, amount) { + const next = Math.min(offset + amount, text.length); + if (next > 0 && next < text.length && text.charCodeAt(next - 1) >= 0xd800 && text.charCodeAt(next - 1) <= 0xdbff && text.charCodeAt(next) >= 0xdc00 && text.charCodeAt(next) <= 0xdfff) { + return next + 1; + } + return next; +} +function reconcilePresentation(previous, targetText, settledText, progressive) { + const displayedText = previous.targetText.slice(0, previous.displayedLen); + const retainedLen = commonPrefixLength(displayedText, targetText); + const settledLen = commonPrefixLength(settledText, targetText); + return { + targetText, + settledText, + displayedLen: progressive ? Math.max(retainedLen, settledLen) : targetText.length + }; +} + /** * Snap a UTF-16 code-unit offset down to the start of the grapheme cluster * it falls inside, so slicing `text` at the returned offset never splits a @@ -143,56 +181,47 @@ export function useStreamingText(targetText, isStreaming, options) { const tick = speed === 'fast' ? base / 20 : base / 10; return Math.max(4, Math.round(tick)); }, [token, speed]); - const [displayedLen, setDisplayedLen] = useState(0); - const targetRef = useRef(targetText); - const displayedLenRef = useRef(0); - const lastTickRef = useRef(0); - const rafRef = useRef(null); - - // Keep target ref in sync - targetRef.current = targetText; - - // Reset when target clears (new message) - const prevTargetLenRef = useRef(targetText.length); - if (targetText.length !== prevTargetLenRef.current) { - prevTargetLenRef.current = targetText.length; - if (targetText.length === 0 && displayedLen !== 0) { - displayedLenRef.current = 0; - setDisplayedLen(0); - } - } - - // Snap to full text when streaming ends - const prevIsStreamingRef = useRef(isStreaming); - if (isStreaming !== prevIsStreamingRef.current) { - prevIsStreamingRef.current = isStreaming; - if (!isStreaming && targetText.length > 0) { - displayedLenRef.current = targetText.length; - setDisplayedLen(targetText.length); - } - } - // Respect the user's reduced-motion preference — skip the progressive // reveal and snap straight to the full text. Uses the package's SSR-safe // useMediaQuery (useMediaQuery.ts) so the read stays in sync if the // preference changes mid-stream. const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const progressive = isStreaming && speed !== 'instant' && !prefersReducedMotion; + const settledText = options?.settledText ?? ''; + const [presentation, setPresentation] = useState(() => ({ + targetText, + settledText, + displayedLen: progressive ? commonPrefixLength(settledText, targetText) : targetText.length + })); + let renderedPresentation = presentation; + if (presentation.targetText !== targetText || presentation.settledText !== settledText || !progressive && presentation.displayedLen !== targetText.length) { + renderedPresentation = reconcilePresentation(presentation, targetText, settledText, progressive); + setPresentation(renderedPresentation); + } + const presentationRef = useRef(presentation); + const lastTickRef = useRef(0); + const rafRef = useRef(null); + useLayoutEffect(() => { + presentationRef.current = presentation; + }, [presentation]); // Animation loop useEffect(() => { - if (!isStreaming || speed === 'instant' || prefersReducedMotion) { + if (!progressive) { return; } function tick(now) { const elapsed = now - lastTickRef.current; if (elapsed >= tickMs) { lastTickRef.current = now; - const target = targetRef.current; - const currentLen = displayedLenRef.current; - if (currentLen < target.length) { - const nextLen = Math.min(currentLen + charsPerTick, target.length); - displayedLenRef.current = nextLen; - setDisplayedLen(nextLen); + const current = presentationRef.current; + if (current.displayedLen < current.targetText.length) { + const next = { + ...current, + displayedLen: advanceTextOffset(current.targetText, current.displayedLen, charsPerTick) + }; + presentationRef.current = next; + setPresentation(next); } } rafRef.current = requestAnimationFrame(tick); @@ -204,9 +233,9 @@ export function useStreamingText(targetText, isStreaming, options) { rafRef.current = null; } }; - }, [isStreaming, charsPerTick, tickMs, speed, prefersReducedMotion]); - if (!isStreaming || speed === 'instant' || prefersReducedMotion) { + }, [progressive, charsPerTick, tickMs]); + if (!progressive) { return targetText; } - return targetText.slice(0, snapToGraphemeBoundary(targetText, displayedLen)); + return targetText.slice(0, snapToGraphemeBoundary(targetText, renderedPresentation.displayedLen)); } diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx index f22bb1f..b710951 100644 --- a/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx +++ b/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx @@ -399,6 +399,7 @@ function CallRow({call}: {call: ChatToolCallItem}) {
{ */ headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6; isStreaming?: boolean; + /** Source text the host has authoritatively marked as already presented. */ + settledText?: string; + /** Transform displayed source immediately before Markdown parsing. */ + transformSource?: (source: string) => string; onLinkClick?: ( href: string, event: React.MouseEvent, @@ -1615,6 +1619,8 @@ export function Markdown({ density = 'default', headingLevelStart = 1, isStreaming = false, + settledText, + transformSource, onLinkClick, sources, citationStyle = 'label', @@ -1644,18 +1650,14 @@ export function Markdown({ // Smooth bursty streamed chunks into a steady character-by-character reveal. // When not streaming, the hook returns children unchanged (no-op). - const smoothedText = useStreamingText(children, isStreaming); + const smoothedText = useStreamingText(children, isStreaming, { + settledText, + }); + const parsedText = transformSource ? transformSource(smoothedText) : smoothedText; const incrementalStateRef = useRef( createIncrementalState(), ); - // Reset incremental cache when the autolink option toggles — cached - // settled blocks were parsed with the previous setting. - const prevAutolinkRef = useRef(autolink); - if (prevAutolinkRef.current !== autolink) { - incrementalStateRef.current = createIncrementalState(); - prevAutolinkRef.current = autolink; - } const blocks = useMemo(() => { if (display === 'inline') { @@ -1666,15 +1668,15 @@ export function Markdown({ incrementalStateRef.current = createIncrementalState(); return []; } - const input = trimStreamingArtifacts(smoothedText); + const input = trimStreamingArtifacts(parsedText); return parseMarkdownIncremental( input, incrementalStateRef.current, parseOptions, ); } - return parseMarkdown(children, parseOptions); - }, [display, smoothedText, children, isStreaming, parseOptions]); + return parseMarkdown(parsedText, parseOptions); + }, [display, parsedText, isStreaming, parseOptions]); // Assign each top-level heading the slug that parseOutlineFromMarkdown // would derive for it, so Outline hash links built from the same source @@ -1701,9 +1703,9 @@ export function Markdown({ if (display !== 'inline') { return []; } - const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children; + const input = isStreaming ? trimStreamingArtifacts(parsedText) : parsedText; return parseInline(input, parseOptions); - }, [display, smoothedText, children, isStreaming, parseOptions]); + }, [display, parsedText, isStreaming, parseOptions]); // Track recent boundaries for stacked fade-in animation. // The number of spans needed = ceil(animationDuration / tickInterval). @@ -1719,20 +1721,43 @@ export function Markdown({ return Math.min(Math.ceil(duration / tickMs), 12); }, [token]); - const prevBlocksRef = useRef([]); - const prevInlineNodesRef = useRef([]); - const boundariesRef = useRef([]); - const smoothedLen = smoothedText.length; - const boundaries = useMemo(() => { - void smoothedLen; // dep trigger - const prevLen = - display === 'inline' - ? countInlineTextLength(prevInlineNodesRef.current) - : countBlockTextLength(prevBlocksRef.current); - const next = computeBoundaries(boundariesRef.current, prevLen, maxSpans); - boundariesRef.current = next; - return next; - }, [display, smoothedLen, maxSpans]); + const visibleLength = display === 'inline' + ? countInlineTextLength(inlineNodes) + : countBlockTextLength(blocks); + const [fadeState, setFadeState] = useState(() => ({ + display, + settledText, + smoothedText, + visibleLength, + boundaries: settledText === undefined ? [0] : [visibleLength], + })); + const resetBoundaries = fadeState.display !== display + || fadeState.settledText !== settledText + || !smoothedText.startsWith(fadeState.smoothedText); + const boundaries = resetBoundaries + ? [visibleLength] + : fadeState.smoothedText === smoothedText + ? fadeState.boundaries + : computeBoundaries( + fadeState.boundaries, + fadeState.visibleLength, + maxSpans, + ); + if ( + fadeState.display !== display + || fadeState.settledText !== settledText + || fadeState.smoothedText !== smoothedText + || fadeState.visibleLength !== visibleLength + || fadeState.boundaries !== boundaries + ) { + setFadeState({ + display, + settledText, + smoothedText, + visibleLength, + boundaries, + }); + } const cursor: StreamingCursor = { offset: 0, @@ -1774,10 +1799,6 @@ export function Markdown({ ); - // Store current inline nodes for next render's boundary calculation. - prevInlineNodesRef.current = inlineNodes; - prevBlocksRef.current = []; - return renderedInline; } @@ -1821,12 +1842,6 @@ export function Markdown({
); - // Store current blocks for next render's boundary calculation. - // This ref write is safe under StrictMode: both invocations produce the same - // blocks (same smoothedText → same useMemo result), so both write the same value. - prevBlocksRef.current = blocks; - prevInlineNodesRef.current = []; - return rendered; } diff --git a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx b/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx index 1197085..67271f5 100644 --- a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx +++ b/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx @@ -346,6 +346,12 @@ export interface SideNavItemProps extends BaseProps { * renders inside the primary link or button. */ endContent?: ReactNode; + /** + * Interactive trailing control rendered after the navigation control and + * before any nested items. Unlike `endContent`, this is a sibling rather + * than content inside the navigation control. + */ + trailingAction?: ReactNode; /** * Row-level secondary controls (icon buttons, menus) rendered as siblings * of the primary element at the trailing edge of the row — after the @@ -432,6 +438,7 @@ export function SideNavItem({ href, onClick, endContent, + trailingAction, actions, children, collapsible: itemCollapsible, @@ -792,6 +799,7 @@ export function SideNavItem({ const item = (
{itemElement} + {trailingAction} {hasChildren && !isCollapsed && (
0xffff ? 2 : 1; + } + return offset; +} + +/** + * Advances a UTF-16 offset without stopping between a surrogate pair. + * + * The rendered slice is snapped *back* to a grapheme boundary + * (snapToGraphemeBoundary), so a tick landing inside a pair would otherwise + * hold the code point back a frame; advancing past it reveals the glyph on + * the tick that reached it. + */ +function advanceTextOffset(text: string, offset: number, amount: number): number { + const next = Math.min(offset + amount, text.length); + if ( + next > 0 + && next < text.length + && text.charCodeAt(next - 1) >= 0xd800 + && text.charCodeAt(next - 1) <= 0xdbff + && text.charCodeAt(next) >= 0xdc00 + && text.charCodeAt(next) <= 0xdfff + ) { + return next + 1; + } + return next; +} + +interface StreamingPresentation { + targetText: string; + settledText: string; + displayedLen: number; +} + +function reconcilePresentation( + previous: StreamingPresentation, + targetText: string, + settledText: string, + progressive: boolean, +): StreamingPresentation { + const displayedText = previous.targetText.slice(0, previous.displayedLen); + const retainedLen = commonPrefixLength(displayedText, targetText); + const settledLen = commonPrefixLength(settledText, targetText); + return { + targetText, + settledText, + displayedLen: progressive + ? Math.max(retainedLen, settledLen) + : targetText.length, + }; } // Fallback values when no Theme provider is present @@ -159,35 +220,6 @@ export function useStreamingText( return Math.max(4, Math.round(tick)); }, [token, speed]); - const [displayedLen, setDisplayedLen] = useState(0); - const targetRef = useRef(targetText); - const displayedLenRef = useRef(0); - const lastTickRef = useRef(0); - const rafRef = useRef(null); - - // Keep target ref in sync - targetRef.current = targetText; - - // Reset when target clears (new message) - const prevTargetLenRef = useRef(targetText.length); - if (targetText.length !== prevTargetLenRef.current) { - prevTargetLenRef.current = targetText.length; - if (targetText.length === 0 && displayedLen !== 0) { - displayedLenRef.current = 0; - setDisplayedLen(0); - } - } - - // Snap to full text when streaming ends - const prevIsStreamingRef = useRef(isStreaming); - if (isStreaming !== prevIsStreamingRef.current) { - prevIsStreamingRef.current = isStreaming; - if (!isStreaming && targetText.length > 0) { - displayedLenRef.current = targetText.length; - setDisplayedLen(targetText.length); - } - } - // Respect the user's reduced-motion preference — skip the progressive // reveal and snap straight to the full text. Uses the package's SSR-safe // useMediaQuery (useMediaQuery.ts) so the read stays in sync if the @@ -195,10 +227,40 @@ export function useStreamingText( const prefersReducedMotion = useMediaQuery( '(prefers-reduced-motion: reduce)', ); + const progressive = isStreaming && speed !== 'instant' && !prefersReducedMotion; + const settledText = options?.settledText ?? ''; + const [presentation, setPresentation] = useState(() => ({ + targetText, + settledText, + displayedLen: progressive + ? commonPrefixLength(settledText, targetText) + : targetText.length, + })); + let renderedPresentation = presentation; + if ( + presentation.targetText !== targetText + || presentation.settledText !== settledText + || (!progressive && presentation.displayedLen !== targetText.length) + ) { + renderedPresentation = reconcilePresentation( + presentation, + targetText, + settledText, + progressive, + ); + setPresentation(renderedPresentation); + } + + const presentationRef = useRef(presentation); + const lastTickRef = useRef(0); + const rafRef = useRef(null); + useLayoutEffect(() => { + presentationRef.current = presentation; + }, [presentation]); // Animation loop useEffect(() => { - if (!isStreaming || speed === 'instant' || prefersReducedMotion) { + if (!progressive) { return; } @@ -206,14 +268,19 @@ export function useStreamingText( const elapsed = now - lastTickRef.current; if (elapsed >= tickMs) { lastTickRef.current = now; - const target = targetRef.current; - const currentLen = displayedLenRef.current; - - if (currentLen < target.length) { - const nextLen = Math.min(currentLen + charsPerTick, target.length); + const current = presentationRef.current; - displayedLenRef.current = nextLen; - setDisplayedLen(nextLen); + if (current.displayedLen < current.targetText.length) { + const next = { + ...current, + displayedLen: advanceTextOffset( + current.targetText, + current.displayedLen, + charsPerTick, + ), + }; + presentationRef.current = next; + setPresentation(next); } } @@ -227,11 +294,14 @@ export function useStreamingText( rafRef.current = null; } }; - }, [isStreaming, charsPerTick, tickMs, speed, prefersReducedMotion]); + }, [progressive, charsPerTick, tickMs]); - if (!isStreaming || speed === 'instant' || prefersReducedMotion) { + if (!progressive) { return targetText; } - return targetText.slice(0, snapToGraphemeBoundary(targetText, displayedLen)); + return targetText.slice( + 0, + snapToGraphemeBoundary(targetText, renderedPresentation.displayedLen), + ); }