Files
maka/patches/@astryxdesign+core+0.5.2.patch
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

1184 lines
47 KiB
Diff

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<HTMLDivElement> {
* @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<BaseProps, 'xstyle' | 'class
* @default true
*/
hasCloseOnSelect?: boolean;
+ /**
+ * Exposes WAI-ARIA busy semantics on the row (maka patch): a host that
+ * holds the row's look steady while its backing data refreshes needs to
+ * say so to assistive technology, since activation is deferred meanwhile.
+ */
+ 'aria-busy'?: boolean;
/**
* Visual variant. `'destructive'` renders the label, description, and icon in
* the error color for dangerous actions (e.g. Delete). @default 'default'
@@ -62,7 +68,7 @@ export interface DropdownMenuItemProps extends Pick<BaseProps, 'xstyle' | 'class
* </DropdownMenu>
* ```
*/
-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<HTMLElement> {
*/
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<HTMLAnchorElement>) => void | false;
/**
* Citation sources keyed by ID. When provided, `[id]` and `【id】` markers
@@ -155,8 +159,8 @@ export interface MarkdownProps extends BaseProps<HTMLElement> {
* </Markdown>
* ```
*/
-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<HTMLElement> {
* 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<HTMLElement> {
* </SideNavItem>
* ```
*/
-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}) {
<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/src/Chat/useTriggerMenu.tsx b/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx
index 6eefd4a..17071f1 100644
--- a/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx
+++ b/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx
@@ -167,7 +167,56 @@ const styles = stylex.create({
// Helpers
// =============================================================================
-function getTextBeforeCursor(editable: HTMLDivElement): string | null {
+const blockDisplays = new Set(['block', 'flex', 'flow-root', 'grid', 'list-item', 'table']);
+
+function createsLineBoundary(node: Node): boolean {
+ return (
+ node instanceof HTMLBRElement ||
+ (node instanceof HTMLElement && blockDisplays.has(window.getComputedStyle(node).display))
+ );
+}
+
+function getTrailingContextCharacter(node: Node): string {
+ 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: HTMLDivElement, node: Node): string {
+ let current: Node | null = 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: HTMLDivElement,
+): {text: string; nodeStart: number} | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
@@ -183,7 +232,9 @@ function getTextBeforeCursor(editable: HTMLDivElement): string | null {
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;
@@ -486,23 +537,24 @@ export function useTriggerMenu(
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();
}
return;
}
- const {trigger, query, triggerStart} = found;
+ const {trigger, query} = found;
+ const triggerStart = found.triggerStart - cursorContext.nodeStart;
if (!state.isActive || state.activeTrigger !== trigger) {
triggerStartRef.current = triggerStart;
diff --git a/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx b/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx
index 21bad6a..8e2a03a 100644
--- a/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx
+++ b/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx
@@ -111,6 +111,12 @@ export interface DropdownMenuItemProps extends Pick<
* @default true
*/
hasCloseOnSelect?: boolean;
+ /**
+ * Exposes WAI-ARIA busy semantics on the row (maka patch): a host that
+ * holds the row's look steady while its backing data refreshes needs to
+ * say so to assistive technology, since activation is deferred meanwhile.
+ */
+ 'aria-busy'?: boolean;
/**
* Visual variant. `'destructive'` renders the label, description, and icon in
* the error color for dangerous actions (e.g. Delete). @default 'default'
@@ -134,6 +140,7 @@ export interface DropdownMenuItemProps extends Pick<
*/
export function DropdownMenuItem({
icon,
+ 'aria-busy': ariaBusy,
label,
description,
onClick,
@@ -168,6 +175,7 @@ export function DropdownMenuItem({
return (
<Item
role="menuitem"
+ aria-busy={ariaBusy}
tabIndex={isDisabled ? undefined : -1}
onPointerMove={handlePointerMove}
startContent={
diff --git a/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx b/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx
index 0ba2449..939ddf9 100644
--- a/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx
+++ b/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx
@@ -9,7 +9,7 @@
* @position Core implementation; renders markdown as Astryx components
*/
-import {useMemo, useRef} from 'react';
+import {useMemo, useRef, useState} from 'react';
import type React from 'react';
import {Fragment} from 'react';
import * as stylex from '@stylexjs/stylex';
@@ -145,6 +145,10 @@ export interface MarkdownProps extends BaseProps<HTMLElement> {
*/
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<HTMLAnchorElement>,
@@ -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<IncrementalState>(
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<BlockNode[]>([]);
- const prevInlineNodesRef = useRef<InlineNode[]>([]);
- const boundariesRef = useRef<number[]>([]);
- 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({
</span>
);
- // Store current inline nodes for next render's boundary calculation.
- prevInlineNodesRef.current = inlineNodes;
- prevBlocksRef.current = [];
-
return renderedInline;
}
@@ -1821,12 +1842,6 @@ export function Markdown({
</div>
);
- // 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<HTMLElement> {
* 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 = (
<div ref={itemRef} {...stylex.props(styles.root, xstyle)}>
{itemElement}
+ {trailingAction}
{hasChildren && !isCollapsed && (
<div
id={`${id}-children`}
diff --git a/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts b/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts
index 55b441e..7aa4174 100644
--- a/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts
+++ b/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts
@@ -25,7 +25,7 @@
* - /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';
import {useTheme} from '../theme/useTheme';
@@ -43,6 +43,67 @@ export interface UseStreamingTextOptions {
* @default 'natural'
*/
speed?: StreamingTextSpeed;
+ /** Source text the host has authoritatively marked as already presented. */
+ settledText?: string;
+}
+
+/** Longest shared prefix as a UTF-16 offset, without splitting a surrogate pair. */
+function commonPrefixLength(a: string, b: string): number {
+ 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: 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<number | null>(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<StreamingPresentation>(() => ({
+ 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<number | null>(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),
+ );
}