diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 22df8a6fcf0c..7f10141b1c87 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -1122,6 +1122,7 @@ export function ChatPage({ "Continue Generating (pick up exactly where you left off)", }); }; + const [gener, setFinishedStreaming] = useState(false); const onSubmit = async ({ messageIdToResend, @@ -1272,6 +1273,7 @@ export function ChatPage({ let finalMessage: BackendMessage | null = null; let toolCall: ToolCallMetadata | null = null; let isImprovement: boolean | undefined = undefined; + let isStreamingQuestions = true; let initialFetchDetails: null | { user_message_id: number; @@ -1442,11 +1444,22 @@ export function ChatPage({ Object.hasOwn(packet, "stop_reason") && Object.hasOwn(packet, "level_question_num") ) { + if ((packet as StreamStopInfo).stream_type == "main_answer") { + setFinishedStreaming(true); + updateChatState("streaming", frozenSessionId); + } + if ( + (packet as StreamStopInfo).stream_type == "sub_questions" && + (packet as StreamStopInfo).level_question_num == undefined + ) { + isStreamingQuestions = false; + } sub_questions = constructSubQuestions( sub_questions, packet as StreamStopInfo ); } else if (Object.hasOwn(packet, "sub_question")) { + updateChatState("toolBuilding", frozenSessionId); is_generating = true; sub_questions = constructSubQuestions( sub_questions, @@ -1606,6 +1619,7 @@ export function ChatPage({ latestChildMessageId: initialFetchDetails.assistant_message_id, }, { + isStreamingQuestions: isStreamingQuestions, is_generating: is_generating, isImprovement: isImprovement, messageId: initialFetchDetails.assistant_message_id!, @@ -2637,6 +2651,12 @@ export function ChatPage({ {message.sub_questions && message.sub_questions.length > 0 ? ( diff --git a/web/src/app/chat/Refinement.tsx b/web/src/app/chat/Refinement.tsx index 513b816c7318..2b3fc0352d97 100644 --- a/web/src/app/chat/Refinement.tsx +++ b/web/src/app/chat/Refinement.tsx @@ -115,21 +115,61 @@ export function RefinemenetBadge({ const isDone = displayedPhases.includes(StreamingPhase.COMPLETE); // Expand/collapse, hover states - const [expanded, setExpanded] = useState(true); + const [expanded] = useState(true); const [toolTipHoveredInternal, setToolTipHoveredInternal] = useState(false); const [isHovered, setIsHovered] = useState(false); const [shouldShow, setShouldShow] = useState(true); + // Refs for bounding area checks + const containerRef = useRef(null); + const tooltipRef = useRef(null); + + // Keep the tooltip open if hovered on container or tooltip + // Remove the old onMouseLeave calls and rely on bounding area checks + useEffect(() => { + function handleMouseMove(e: MouseEvent) { + if (!containerRef.current || !tooltipRef.current) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const [x, y] = [e.clientX, e.clientY]; + + const inContainer = + x >= containerRect.left && + x <= containerRect.right && + y >= containerRect.top && + y <= containerRect.bottom; + + const inTooltip = + x >= tooltipRect.left && + x <= tooltipRect.right && + y >= tooltipRect.top && + y <= tooltipRect.bottom; + + // If not hovering in either region, close tooltip + if (!inContainer && !inTooltip) { + setToolTipHoveredInternal(false); + setToolTipHovered(false); + setIsHovered(false); + } + } + + window.addEventListener("mousemove", handleMouseMove); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, [setToolTipHovered]); + // Once "done", hide after a short delay if not hovered useEffect(() => { if (isDone) { const timer = setTimeout(() => { setShouldShow(false); setCanShowResponse(true); - }, 800); // e.g. 0.8s + }, 800); return () => clearTimeout(timer); } - }, [isDone, isHovered]); + }, [isDone, isHovered, setCanShowResponse]); if (!shouldShow) { return null; // entire box disappears @@ -137,13 +177,22 @@ export function RefinemenetBadge({ return ( + {/* + IMPORTANT: We rely on open={ isHovered || toolTipHoveredInternal } + to keep the tooltip visible if either the badge or tooltip is hovered. + */}
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + ref={containerRef} + // onMouseEnter keeps the tooltip open + onMouseEnter={() => { + setIsHovered(true); + setToolTipHoveredInternal(true); + setToolTipHovered(true); + }} + // Remove the explicit onMouseLeave – the global bounding check will close it > - {/* Original snippet's tooltip usage */}

@@ -159,36 +208,32 @@ export function RefinemenetBadge({ {expanded && ( { setToolTipHoveredInternal(true); setToolTipHovered(true); }} - onMouseLeave={() => { - setToolTipHoveredInternal(false); - }} + // Remove onMouseLeave and rely on bounding box logic to close side="bottom" align="start" - className="w-fit -mt-1 p-4 bg-white border-2 border-border shadow-lg rounded-md" + width="w-fit" + className=" -mt-1 p-4 bg-[#fff] dark:bg-[#000] border-2 border-border dark:border-neutral-800 shadow-lg rounded-md" > {/* If not done, show the "Refining" box + a chevron */} - {/* Expanded area: each displayed phase in order */}

{currentState !== StreamingPhase.WAITING ? ( Array.from(new Set(displayedPhases)).map((phase, index) => { - const phaseIndex = displayedPhases.indexOf(phase); - // The last displayed item is "running" if not COMPLETE let status = ToggleState.Done; if ( index === - Array.from(new Set(displayedPhases)).length - 1 + Array.from(new Set(displayedPhases)).length - 1 && + phase !== StreamingPhase.COMPLETE ) { status = ToggleState.InProgress; } - if (phase === StreamingPhase.COMPLETE) { - status = ToggleState.Done; - } return (
setToolTipHovered(false)} side="bottom" align="start" + width="w-fit" className="w-fit p-4 bg-[#fff] border-2 border-border dark:border-neutral-800 shadow-lg rounded-md" > {/* If not done, show the "Refining" box + a chevron */} @@ -355,7 +401,6 @@ export function StatusRefinement({
{StreamingPhaseText[phase]} - LLL
))} diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index 8a056eb5f6a9..f8b922bd4d7a 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -819,27 +819,17 @@ export function ChatInputBar({ chatState == "toolBuilding" || chatState == "loading" ? chatState != "streaming" - ? "bg-neutral-900 dark:bg-neutral-400 " - : "bg-neutral-500 dark:bg-neutral-50" - : "" + ? "bg-neutral-500 dark:bg-neutral-400 " + : "bg-neutral-900 dark:bg-neutral-50" + : "bg-red-200" } h-[22px] w-[22px] rounded-full`} onClick={() => { - if ( - chatState == "streaming" || - chatState == "toolBuilding" || - chatState == "loading" - ) { + if (chatState == "streaming") { stopGenerating(); } else if (message) { onSubmit(); } }} - disabled={ - (chatState == "streaming" || - chatState == "toolBuilding" || - chatState == "loading") && - chatState != "streaming" - } > {chatState == "streaming" || chatState == "toolBuilding" || diff --git a/web/src/app/chat/interfaces.ts b/web/src/app/chat/interfaces.ts index 301239e4d352..343097f8e854 100644 --- a/web/src/app/chat/interfaces.ts +++ b/web/src/app/chat/interfaces.ts @@ -110,6 +110,7 @@ export interface Message { second_level_message?: string; second_level_subquestions?: SubQuestionDetail[] | null; isImprovement?: boolean | null; + isStreamingQuestions?: boolean; } export interface BackendChatSession { @@ -219,6 +220,7 @@ export interface SubQuestionDetail extends BaseQuestionIdentifier { context_docs?: { top_documents: OnyxDocument[] } | null; is_complete?: boolean; is_stopped?: boolean; + answer_streaming?: boolean; } export interface SubQueryDetail { @@ -245,9 +247,6 @@ export const constructSubQuestions = ( } const updatedSubQuestions = [...subQuestions]; - // .filter( - // (sq) => sq.level_question_num !== 0 - // ); if ("stop_reason" in newDetail) { const { level, level_question_num } = newDetail; @@ -255,8 +254,12 @@ export const constructSubQuestions = ( (sq) => sq.level === level && sq.level_question_num === level_question_num ); if (subQuestion) { - subQuestion.is_complete = true; - subQuestion.is_stopped = true; + if (newDetail.stream_type == "sub_answer") { + subQuestion.answer_streaming = false; + } else { + subQuestion.is_complete = true; + subQuestion.is_stopped = true; + } } } else if ("top_documents" in newDetail) { const { level, level_question_num, top_documents } = newDetail; diff --git a/web/src/app/chat/message/AgenticMessage.tsx b/web/src/app/chat/message/AgenticMessage.tsx index 637a17671eb7..e1bb48c4be62 100644 --- a/web/src/app/chat/message/AgenticMessage.tsx +++ b/web/src/app/chat/message/AgenticMessage.tsx @@ -48,9 +48,10 @@ import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import SubQuestionsDisplay from "./SubQuestionsDisplay"; import { StatusRefinement } from "../Refinement"; -import SubQuestionProgress from "./SubQuestionProgress"; export const AgenticMessage = ({ + isStreamingQuestions, + isGenerating, docSidebarToggled, isImprovement, secondLevelAssistantMessage, @@ -81,6 +82,8 @@ export const AgenticMessage = ({ secondLevelSubquestions, toggleDocDisplay, }: { + isStreamingQuestions: boolean; + isGenerating: boolean; docSidebarToggled?: boolean; isImprovement?: boolean | null; secondLevelSubquestions?: SubQuestionDetail[] | null; @@ -230,6 +233,13 @@ export const AgenticMessage = ({ ); const [currentlyOpenQuestion, setCurrentlyOpenQuestion] = useState(null); + const [finishedGenerating, setFinishedGenerating] = useState(!isGenerating); + + useEffect(() => { + if (streamedContent.length == finalContent.length && !isGenerating) { + setFinishedGenerating(true); + } + }, [streamedContent, finalContent, isGenerating]); const openQuestion = useCallback( (question: SubQuestionDetail) => { @@ -400,12 +410,10 @@ export const AgenticMessage = ({
{subQuestions && subQuestions.length > 0 && ( setAllowDocuments(true)} docSidebarToggled={docSidebarToggled || false} - finishedGenerating={ - finalContent.length > 2 && - streamedContent.length == finalContent.length - } + finishedGenerating={finishedGenerating} overallAnswerGenerating={ !!( secondLevelSubquestions && diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index 4477f79f5faa..f389c458d6d5 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -957,7 +957,7 @@ export const HumanMessage = ({ min-h-[38px] py-2 px-3 - hover:bg-accent-hover + hover:bg-agent-hovered `} onClick={handleEditSubmit} > diff --git a/web/src/app/chat/message/StreamingMessages.ts b/web/src/app/chat/message/StreamingMessages.ts index 7cd725cf4a77..d3bd8ef333fc 100644 --- a/web/src/app/chat/message/StreamingMessages.ts +++ b/web/src/app/chat/message/StreamingMessages.ts @@ -56,12 +56,19 @@ const DOC_DELAY_MS = 100; export const useStreamingMessages = ( subQuestions: SubQuestionDetail[], allowStreaming: () => void, - onComplete: () => void + onComplete: () => void, + isStreamingQuestions: boolean ) => { const [dynamicSubQuestions, setDynamicSubQuestions] = useState< SubQuestionDetail[] >([]); + const isStreamingQuestionsRef = useRef(isStreamingQuestions); + + useEffect(() => { + isStreamingQuestionsRef.current = isStreamingQuestions; + }, [isStreamingQuestions]); + const subQuestionsRef = useRef(subQuestions); useEffect(() => { subQuestionsRef.current = subQuestions; @@ -121,6 +128,7 @@ export const useStreamingMessages = ( // Stream high-level questions sequentially let didStreamQuestion = false; let allQuestionsComplete = true; + for (let i = 0; i < actualSubQs.length; i++) { const sq = actualSubQs[i]; const p = progressRef.current[i]; @@ -138,6 +146,8 @@ export const useStreamingMessages = ( p.questionDone = true; } didStreamQuestion = true; + allQuestionsComplete = false; + // Break after streaming one question to ensure sequential behavior break; } @@ -149,7 +159,11 @@ export const useStreamingMessages = ( } } - if (allQuestionsComplete && !didStreamQuestion) { + if ( + allQuestionsComplete && + !didStreamQuestion && + !isStreamingQuestionsRef.current + ) { onComplete(); } @@ -163,6 +177,8 @@ export const useStreamingMessages = ( for (let i = 0; i < actualSubQs.length; i++) { const sq = actualSubQs[i]; const dynSQ = dynamicSubQuestionsRef.current[i]; + dynSQ.answer_streaming = sq.answer_streaming; + const p = progressRef.current[i]; // Wait for subquestion #0 or the previous subquestion's progress diff --git a/web/src/app/chat/message/SubQuestionsDisplay.tsx b/web/src/app/chat/message/SubQuestionsDisplay.tsx index 58f571f96d20..782c4a289b7d 100644 --- a/web/src/app/chat/message/SubQuestionsDisplay.tsx +++ b/web/src/app/chat/message/SubQuestionsDisplay.tsx @@ -65,6 +65,7 @@ export interface TemporaryDisplay { tinyQuestion: string; } interface SubQuestionsDisplayProps { + isStreamingQuestions: boolean; docSidebarToggled: boolean; finishedGenerating: boolean; currentlyOpenQuestion?: BaseQuestionIdentifier | null; @@ -152,7 +153,8 @@ const SubQuestionDisplay: React.FC<{ content = content.replace(/\]\](?!\()/g, "]]()"); return ( - preprocessLaTeX(content) + (!subQuestion?.is_complete ? " [*]() " : "") + preprocessLaTeX(content) + + (subQuestion?.answer_streaming ? " [*]() " : "") ); }; @@ -461,6 +463,7 @@ const SubQuestionDisplay: React.FC<{ }; const SubQuestionsDisplay: React.FC = ({ + isStreamingQuestions, finishedGenerating, subQuestions, allowStreaming, @@ -477,23 +480,29 @@ const SubQuestionsDisplay: React.FC = ({ const [showSummarizing, setShowSummarizing] = useState( finishedGenerating && !overallAnswerGenerating ); + const [initiallyFinishedGenerating, setInitiallyFinishedGenerating] = + useState(finishedGenerating); + // const [] const { dynamicSubQuestions } = useStreamingMessages( subQuestions, () => {}, () => { setShowSummarizing(true); - } + }, + isStreamingQuestions ); const { dynamicSubQuestions: dynamicSecondLevelQuestions } = useStreamingMessages( secondLevelQuestions || [], () => {}, - () => {} + () => {}, + false ); + const memoizedSubQuestions = useMemo(() => { - return finishedGenerating ? subQuestions : dynamicSubQuestions; - }, [finishedGenerating, dynamicSubQuestions, subQuestions]); - // const memoizedSubQuestions = dynamicSubQuestions; + return initiallyFinishedGenerating ? subQuestions : dynamicSubQuestions; + }, [initiallyFinishedGenerating, dynamicSubQuestions, subQuestions]); + const memoizedSecondLevelQuestions = useMemo(() => { return overallAnswerGenerating ? dynamicSecondLevelQuestions @@ -509,12 +518,6 @@ const SubQuestionsDisplay: React.FC = ({ (subQuestion) => (subQuestion?.sub_queries || [])?.length > 0 ).length == 0; - const overallAnswer = - memoizedSubQuestions.length > 0 && - memoizedSubQuestions.filter( - (subQuestion) => subQuestion?.answer.length > 10 - ).length == memoizedSubQuestions.length; - const [streamedText, setStreamedText] = useState( finishedGenerating ? "Summarize findings" : "" ); @@ -524,12 +527,15 @@ const SubQuestionsDisplay: React.FC = ({ const [shownDocuments, setShownDocuments] = useState(documents); useEffect(() => { - if (documents && documents.length > 0) { - setTimeout(() => { - setShownDocuments(documents); - }, 800); + if (canShowSummarizing && documents && documents.length > 0) { + setTimeout( + () => { + setShownDocuments(documents); + }, + finishedGenerating ? 0 : 800 + ); } - }, [documents]); + }, [documents, canShowSummarizing]); useEffect(() => { if ( diff --git a/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx b/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx index fa5684174b3e..73fbc363978f 100644 --- a/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx +++ b/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx @@ -263,6 +263,8 @@ export function SharedChatDisplay({ ) { return ( li > p { color: white; } -.dark li { +.dark li, +.dark h1, +.dark h2, +.dark h3, +.dark h4, +.dark h5 { color: #e5e5e5; } diff --git a/web/src/components/Status.tsx b/web/src/components/Status.tsx index 0990968c5513..cdeeb5ff4a04 100644 --- a/web/src/components/Status.tsx +++ b/web/src/components/Status.tsx @@ -74,7 +74,7 @@ export function IndexAttemptStatus({ ); } else if (status === "not_started") { badge = ( - + Scheduled ); diff --git a/web/src/components/search/results/Citation.tsx b/web/src/components/search/results/Citation.tsx index 2d07b1a379ed..f081aaf36085 100644 --- a/web/src/components/search/results/Citation.tsx +++ b/web/src/components/search/results/Citation.tsx @@ -65,7 +65,7 @@ export function Citation({ {document_info?.document ? ( diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 2dc1d17d25d4..c92ef58ea01b 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -37,7 +37,7 @@ const badgeVariants = cva( destructive: "border-red-200 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900 dark:text-neutral-50", not_started: - "border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100", + "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-900 dark:text-purple-100", }, }, defaultVariants: { diff --git a/web/src/lib/search/interfaces.ts b/web/src/lib/search/interfaces.ts index e6cf93a96d4c..cad836d2d028 100644 --- a/web/src/lib/search/interfaces.ts +++ b/web/src/lib/search/interfaces.ts @@ -76,6 +76,7 @@ export interface StreamStopInfo { stop_reason: StreamStopReason; level?: number; level_question_num?: number; + stream_type?: "sub_answer" | "sub_questions" | "main_answer"; } export interface ErrorMessagePacket { diff --git a/web/tailwind-themes/tailwind.config.js b/web/tailwind-themes/tailwind.config.js index 8453478c6790..e971ed247b08 100644 --- a/web/tailwind-themes/tailwind.config.js +++ b/web/tailwind-themes/tailwind.config.js @@ -263,7 +263,7 @@ module.exports = { "agent-sidebar": "var(--agent-sidebar)", agent: "var(--agent)", "lighter-agent": "var(--lighter-agent)", - + "agent-hovered": "var(--agent-hovered)", // hover "hover-light": "var(--hover-light)", "hover-lightish": "var(--neutral-125)",