mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-16 23:35:46 +00:00
fix(ui): fix few common ui bugs (#8425)
This commit is contained in:
@@ -49,6 +49,7 @@ export const ParallelStreamingHeader = React.memo(
|
||||
collapsible ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
icon={isExpanded ? SvgFold : SvgExpand}
|
||||
aria-label={
|
||||
@@ -58,6 +59,7 @@ export const ParallelStreamingHeader = React.memo(
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
className="bg-transparent"
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Tabs.Trigger
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { JSX, useState, useEffect, useRef } from "react";
|
||||
import React, { JSX, useState, useEffect, useRef, useMemo } from "react";
|
||||
import { SourceTag, SourceInfo } from "@/refresh-components/buttons/source-tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -35,55 +35,33 @@ export function SearchChipList<T>({
|
||||
showDetailsCard,
|
||||
isQuery,
|
||||
}: SearchChipListProps<T>): JSX.Element {
|
||||
const [displayList, setDisplayList] = useState<DisplayEntry<T>[]>([]);
|
||||
const [batchId, setBatchId] = useState(0);
|
||||
const [visibleCount, setVisibleCount] = useState(initialCount);
|
||||
const animatedKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const getEntryKey = (entry: DisplayEntry<T>): string => {
|
||||
if (entry.type === "more") return `more-button-${entry.batchId}`;
|
||||
if (entry.type === "more") return `more-button`;
|
||||
return String(getKey(entry.item, entry.index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const initial: DisplayEntry<T>[] = items
|
||||
.slice(0, initialCount)
|
||||
const effectiveCount = Math.min(visibleCount, items.length);
|
||||
|
||||
const displayList: DisplayEntry<T>[] = useMemo(() => {
|
||||
const chips: DisplayEntry<T>[] = items
|
||||
.slice(0, effectiveCount)
|
||||
.map((item, i) => ({ type: "chip" as const, item, index: i }));
|
||||
|
||||
if (items.length > initialCount) {
|
||||
initial.push({ type: "more", batchId: 0 });
|
||||
if (effectiveCount < items.length) {
|
||||
chips.push({ type: "more", batchId: 0 });
|
||||
}
|
||||
return chips;
|
||||
}, [items, effectiveCount]);
|
||||
|
||||
setDisplayList(initial);
|
||||
setBatchId(0);
|
||||
}, [items, initialCount]);
|
||||
|
||||
const chipCount = displayList.filter((e) => e.type === "chip").length;
|
||||
const chipCount = effectiveCount;
|
||||
const remainingCount = items.length - chipCount;
|
||||
const remainingItems = items.slice(chipCount);
|
||||
|
||||
const handleShowMore = () => {
|
||||
const nextBatchId = batchId + 1;
|
||||
|
||||
setDisplayList((prev) => {
|
||||
const withoutButton = prev.filter((e) => e.type !== "more");
|
||||
const currentCount = withoutButton.length;
|
||||
const newCount = Math.min(currentCount + expansionCount, items.length);
|
||||
const newItems: DisplayEntry<T>[] = items
|
||||
.slice(currentCount, newCount)
|
||||
.map((item, i) => ({
|
||||
type: "chip" as const,
|
||||
item,
|
||||
index: currentCount + i,
|
||||
}));
|
||||
|
||||
const updated = [...withoutButton, ...newItems];
|
||||
if (newCount < items.length) {
|
||||
updated.push({ type: "more", batchId: nextBatchId });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
setBatchId(nextBatchId);
|
||||
setVisibleCount((prev) => prev + expansionCount);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -615,7 +615,13 @@ const TabsTrigger = React.forwardRef<
|
||||
<Icon size={14} className={cn(iconVariants[variant])} />
|
||||
</div>
|
||||
)}
|
||||
{typeof children === "string" ? <Text>{children}</Text> : children}
|
||||
{typeof children === "string" ? (
|
||||
<div className="px-0.5">
|
||||
<Text>{children}</Text>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{isLoading && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin ml-1"
|
||||
|
||||
@@ -171,9 +171,11 @@ const SourceTagDetailsCardInner = ({
|
||||
|
||||
{/* Description */}
|
||||
{currentSource.description && (
|
||||
<Text secondaryBody text03 as="span" className="line-clamp-4">
|
||||
{currentSource.description}
|
||||
</Text>
|
||||
<div className="px-1.5 pb-1">
|
||||
<Text secondaryBody text03 as="span" className="line-clamp-4">
|
||||
{currentSource.description}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useLayoutEffect, useEffect } from "react";
|
||||
import { useState, useMemo, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
@@ -81,6 +81,34 @@ function downloadAsTxt(content: string, filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Block-level HTML tags used by the snap algorithm to recurse into containers. */
|
||||
const CONTAINER_TAGS = new Set([
|
||||
"UL",
|
||||
"OL",
|
||||
"LI",
|
||||
"BLOCKQUOTE",
|
||||
"DIV",
|
||||
"DL",
|
||||
"DD",
|
||||
"TABLE",
|
||||
"TBODY",
|
||||
"THEAD",
|
||||
"TR",
|
||||
"TH",
|
||||
"TD",
|
||||
"SECTION",
|
||||
"DETAILS",
|
||||
"PRE",
|
||||
"FIGURE",
|
||||
"FIGCAPTION",
|
||||
"ARTICLE",
|
||||
"ASIDE",
|
||||
"HEADER",
|
||||
"FOOTER",
|
||||
"MAIN",
|
||||
"NAV",
|
||||
]);
|
||||
|
||||
export default function ExpandableTextDisplay({
|
||||
title,
|
||||
content,
|
||||
@@ -94,45 +122,89 @@ export default function ExpandableTextDisplay({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevIsStreamingRef = useRef(isStreaming);
|
||||
const contentInnerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const lineCount = useMemo(() => getLineCount(content), [content]);
|
||||
const contentSize = useMemo(() => getContentSize(content), [content]);
|
||||
const displaySubtitle = subtitle ?? contentSize;
|
||||
|
||||
// Detect truncation for renderContent mode, streaming, and plain text static
|
||||
useLayoutEffect(() => {
|
||||
// Truncation detection (read-only, doesn't need to block paint)
|
||||
useEffect(() => {
|
||||
if (renderContent && scrollRef.current) {
|
||||
// For renderContent mode (streaming or static), use scroll-based detection
|
||||
// CSS line-clamp handles visual truncation, we just need to detect if it happened
|
||||
setIsTruncated(
|
||||
scrollRef.current.scrollHeight > scrollRef.current.clientHeight
|
||||
);
|
||||
} else if (isStreaming) {
|
||||
// For plain text streaming, use line-based detection (still works for plain text)
|
||||
const textToCheck = displayContent ?? content;
|
||||
const lineCount = getLineCount(textToCheck);
|
||||
setIsTruncated(lineCount > maxLines);
|
||||
setIsTruncated(getLineCount(textToCheck) > maxLines);
|
||||
} else if (scrollRef.current) {
|
||||
// For plain text static, use scroll-based detection with line-clamp
|
||||
setIsTruncated(
|
||||
scrollRef.current.scrollHeight > scrollRef.current.clientHeight
|
||||
);
|
||||
}
|
||||
}, [isStreaming, renderContent, content, displayContent, maxLines]);
|
||||
|
||||
// Scroll to bottom during streaming for renderContent mode
|
||||
// This creates a "scrolling from bottom" effect showing the latest content
|
||||
// Shift content upward during streaming for renderContent mode,
|
||||
// snapping to element boundaries so blocks are never partially clipped.
|
||||
// Must block paint to avoid flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (isStreaming && renderContent && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
if (
|
||||
!isStreaming ||
|
||||
!renderContent ||
|
||||
!scrollRef.current ||
|
||||
!contentInnerRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [isStreaming, renderContent, content, displayContent]);
|
||||
|
||||
// Track streaming state transitions (no longer need scroll management with top-truncation)
|
||||
useEffect(() => {
|
||||
prevIsStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
const containerHeight = scrollRef.current.clientHeight;
|
||||
const contentHeight = contentInnerRef.current.scrollHeight;
|
||||
let overflow = Math.max(0, contentHeight - containerHeight);
|
||||
|
||||
if (overflow > 0) {
|
||||
let blockParent: Element = contentInnerRef.current;
|
||||
while (
|
||||
blockParent.children.length === 1 &&
|
||||
blockParent.children[0]!.children.length > 0
|
||||
) {
|
||||
blockParent = blockParent.children[0]!;
|
||||
}
|
||||
|
||||
contentInnerRef.current.style.transform = "translateY(0)";
|
||||
const refTop = contentInnerRef.current.getBoundingClientRect().top;
|
||||
|
||||
let snapParent: Element = blockParent;
|
||||
let snap = overflow;
|
||||
while (true) {
|
||||
let found = false;
|
||||
for (let i = 0; i < snapParent.children.length; i++) {
|
||||
const child = snapParent.children[i] as HTMLElement;
|
||||
const rect = child.getBoundingClientRect();
|
||||
const top = rect.top - refTop;
|
||||
const bottom = top + rect.height;
|
||||
if (top < snap && snap < bottom) {
|
||||
if (
|
||||
child.children.length > 0 &&
|
||||
CONTAINER_TAGS.has(child.tagName)
|
||||
) {
|
||||
snapParent = child;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
snap = bottom;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
if (snap !== overflow) break;
|
||||
}
|
||||
overflow = snap;
|
||||
}
|
||||
|
||||
contentInnerRef.current.style.transform =
|
||||
overflow > 0 ? `translateY(-${overflow}px)` : "translateY(0)";
|
||||
}, [isStreaming, renderContent, content, displayContent, maxLines]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const sanitizedTitle = title.replace(/[^a-z0-9]/gi, "_").toLowerCase();
|
||||
@@ -160,25 +232,25 @@ export default function ExpandableTextDisplay({
|
||||
const textToDisplay = displayContent ?? content;
|
||||
|
||||
if (isStreaming) {
|
||||
// During streaming: use max-height with overflow-auto to create scrollable container,
|
||||
// then scroll to bottom to show latest content (handled by useLayoutEffect above).
|
||||
// We can't use line-clamp here because it sets overflow:hidden and shows from top,
|
||||
// but we need scrollable overflow to show the latest (bottom) content.
|
||||
// During streaming: use max-height with overflow-hidden and CSS transform to shift
|
||||
// content upward, showing the latest content from the bottom without scroll jitter.
|
||||
// Line height is approximately 1.5rem (24px) for body text.
|
||||
// We show a top ellipsis indicator when content is truncated.
|
||||
return (
|
||||
<div>
|
||||
{isTruncated && (
|
||||
<Text as="span" mainUiMuted text03>
|
||||
<Text as="p" text03 mainUiMuted className="!my-0">
|
||||
…
|
||||
</Text>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto no-scrollbar"
|
||||
className="overflow-hidden"
|
||||
style={{ maxHeight: `calc(${maxLines} * 1.5rem)` }}
|
||||
>
|
||||
{renderContent!(textToDisplay, false)}
|
||||
<div ref={contentInnerRef}>
|
||||
{renderContent!(textToDisplay, false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -234,7 +306,7 @@ export default function ExpandableTextDisplay({
|
||||
|
||||
{/* Expand button - only show when content is truncated */}
|
||||
|
||||
<div className="flex justify-end items-end mt-1 w-8">
|
||||
<div className="flex justify-end self-end mt-1 w-8">
|
||||
{isTruncated && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
|
||||
Reference in New Issue
Block a user