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
1184 lines
47 KiB
Diff
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),
|
|
+ );
|
|
}
|