Compare commits

...

8 Commits

Author SHA1 Message Date
SubashMohan
9abf96f487 chage chat to app path 2026-02-01 12:24:49 +05:30
SubashMohan
67a6266c97 refactor(chat): remove deprecated AIMessage and MultiToolRenderer component (#7927)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:47:30 +05:30
SubashMohan
1a076f557d fix(alembic): update down_revision for processing_duration_seconds migration 2026-01-27 16:56:57 +05:30
SubashMohan
087f6d8f6a feat(chat): show query time in actions block (#7627)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:55:21 +05:30
SubashMohan
040f779b20 feat(chat): enhance timeline transformers and packet processing with detailed examples 2026-01-27 09:07:04 +05:30
SubashMohan
107809543b feat(chat): add AgentMessage and MessageToolbar 2026-01-27 09:07:04 +05:30
SubashMohan
95fd5f81a4 organise files 2026-01-27 09:06:15 +05:30
SubashMohan
94ef6974d6 feat: add packet processor for agent message streaming
- Add packetProcessor.ts with incremental packet processing logic
- Add usePacketProcessor hook for React integration
- Add timeline/transformers.ts for step grouping and parallel tool detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:05:50 +05:30
214 changed files with 5432 additions and 7581 deletions

View File

@@ -0,0 +1,27 @@
"""add processing_duration_seconds to chat_message
Revision ID: 9d1543a37106
Revises: 72aa7de2e5cf
Create Date: 2026-01-21 11:42:18.546188
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "9d1543a37106"
down_revision = "72aa7de2e5cf"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"chat_message",
sa.Column("processing_duration_seconds", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_column("chat_message", "processing_duration_seconds")

View File

@@ -45,6 +45,8 @@ class ChatStateContainer:
self.citation_to_doc: CitationMapping = {}
# True if this turn is a clarification question (deep research flow)
self.is_clarification: bool = False
# Tool processing duration (time before answer starts) in seconds
self.tool_processing_duration: float | None = None
# Note: LLM cost tracking is now handled in multi_llm.py
# Search doc collection - maps dedup key to SearchDoc for all docs from tool calls
self._all_search_docs: dict[SearchDocKey, SearchDoc] = {}
@@ -101,6 +103,16 @@ class ChatStateContainer:
with self._lock:
return self.is_clarification
def set_tool_processing_duration(self, duration: float | None) -> None:
"""Set the tool processing duration (time before answer starts)."""
with self._lock:
self.tool_processing_duration = duration
def get_tool_processing_duration(self) -> float | None:
"""Thread-safe getter for tool_processing_duration."""
with self._lock:
return self.tool_processing_duration
@staticmethod
def create_search_doc_key(
search_doc: SearchDoc, use_simple_key: bool = True

View File

@@ -1,3 +1,4 @@
import time
from collections.abc import Callable
from sqlalchemy.orm import Session
@@ -390,6 +391,9 @@ def run_llm_loop(
initialize_litellm()
# Track processing start time for tool duration calculation
processing_start_time = time.monotonic()
# Initialize citation processor for handling citations dynamically
# When include_citations is True, use HYPERLINK mode to format citations as [[1]](url)
# When include_citations is False, use REMOVE mode to strip citations from output
@@ -551,6 +555,11 @@ def run_llm_loop(
# This calls the LLM, yields packets (reasoning, answers, etc.) and returns the result
# It also pre-processes the tool calls in preparation for running them
tool_defs = [tool.tool_definition() for tool in final_tools]
# Calculate tool processing duration at this point
# This captures the time spent on tool calls before the answer starts streaming
tool_processing_duration = time.monotonic() - processing_start_time
llm_step_result, has_reasoned = run_llm_step(
emitter=emitter,
history=truncated_message_history,
@@ -565,6 +574,7 @@ def run_llm_loop(
# final set of documents immediately if desired.
final_documents=gathered_documents,
user_identity=user_identity,
tool_processing_duration=tool_processing_duration,
)
if has_reasoned:
reasoning_cycles += 1

View File

@@ -622,6 +622,7 @@ def run_llm_step_pkt_generator(
# TODO: Temporary handling of nested tool calls with agents, figure out a better way to handle this
use_existing_tab_index: bool = False,
is_deep_research: bool = False,
tool_processing_duration: float | None = None,
) -> Generator[Packet, None, tuple[LlmStepResult, bool]]:
"""Run an LLM step and stream the response as packets.
NOTE: DO NOT TOUCH THIS FUNCTION BEFORE ASKING YUHONG, this is very finicky and
@@ -822,6 +823,12 @@ def run_llm_step_pkt_generator(
reasoning_start = False
if not answer_start:
# Store tool processing duration in state container for save_chat
if state_container and tool_processing_duration is not None:
state_container.set_tool_processing_duration(
tool_processing_duration
)
yield Packet(
placement=Placement(
turn_index=turn_index,
@@ -830,6 +837,7 @@ def run_llm_step_pkt_generator(
),
obj=AgentResponseStart(
final_documents=final_documents,
tool_processing_duration_seconds=tool_processing_duration,
),
)
answer_start = True
@@ -1038,6 +1046,7 @@ def run_llm_step(
max_tokens: int | None = None,
use_existing_tab_index: bool = False,
is_deep_research: bool = False,
tool_processing_duration: float | None = None,
) -> tuple[LlmStepResult, bool]:
"""Wrapper around run_llm_step_pkt_generator that consumes packets and emits them.
@@ -1059,6 +1068,7 @@ def run_llm_step(
max_tokens=max_tokens,
use_existing_tab_index=use_existing_tab_index,
is_deep_research=is_deep_research,
tool_processing_duration=tool_processing_duration,
)
while True:

View File

@@ -4,6 +4,7 @@ An overview can be found in the README.md file in this directory.
"""
import re
import time
import traceback
from collections.abc import Callable
from uuid import UUID
@@ -312,6 +313,7 @@ def handle_stream_message_objects(
external_state_container: ChatStateContainer | None = None,
) -> AnswerStream:
tenant_id = get_current_tenant_id()
processing_start_time = time.monotonic()
llm: LLM | None = None
chat_session: ChatSession | None = None
@@ -602,6 +604,7 @@ def handle_stream_message_objects(
chat_session_id=str(chat_session.id),
is_connected=check_is_connected,
assistant_message=assistant_response,
processing_start_time=processing_start_time,
)
# Run the LLM loop with explicit wrapper for stop signal handling
@@ -722,6 +725,7 @@ def llm_loop_completion_handle(
db_session: Session,
chat_session_id: str,
assistant_message: ChatMessage,
processing_start_time: float | None = None,
) -> None:
# Determine if stopped by user
completed_normally = is_connected()
@@ -753,6 +757,7 @@ def llm_loop_completion_handle(
assistant_message=assistant_message,
is_clarification=state_container.is_clarification,
emitted_citations=state_container.get_emitted_citations(),
tool_processing_duration=state_container.get_tool_processing_duration(),
)

View File

@@ -145,6 +145,7 @@ def save_chat_turn(
assistant_message: ChatMessage,
is_clarification: bool = False,
emitted_citations: set[int] | None = None,
tool_processing_duration: float | None = None,
) -> None:
"""
Save a chat turn by populating the assistant_message and creating related entities.
@@ -169,12 +170,17 @@ def save_chat_turn(
is_clarification: Whether this assistant message is a clarification question (deep research flow)
emitted_citations: Set of citation numbers that were actually emitted during streaming.
If provided, only citations in this set will be saved; others are filtered out.
tool_processing_duration: Duration of tool processing before answer starts (in seconds)
"""
# 1. Update ChatMessage with message content, reasoning tokens, and token count
assistant_message.message = message_text
assistant_message.reasoning_tokens = reasoning_tokens
assistant_message.is_clarification = is_clarification
# Use tool processing duration (captured when MESSAGE_START was emitted)
if tool_processing_duration is not None:
assistant_message.processing_duration_seconds = tool_processing_duration
# Calculate token count using default tokenizer, when storing, this should not use the LLM
# specific one so we use a system default tokenizer here.
default_tokenizer = get_tokenizer(None, None)

View File

@@ -855,6 +855,7 @@ def translate_db_message_to_chat_message_detail(
files=chat_message.files or [],
error=chat_message.error,
current_feedback=current_feedback,
processing_duration_seconds=chat_message.processing_duration_seconds,
)
return chat_msg_detail

View File

@@ -2173,6 +2173,10 @@ class ChatMessage(Base):
)
# True if this assistant message is a clarification question (deep research flow)
is_clarification: Mapped[bool] = mapped_column(Boolean, default=False)
# Duration in seconds for processing this message (assistant messages only)
processing_duration_seconds: Mapped[float | None] = mapped_column(
Float, nullable=True
)
# Relationships
chat_session: Mapped[ChatSession] = relationship("ChatSession")

View File

@@ -3,6 +3,7 @@
# 2. Use user provided custom prompts
# 3. Save the plan for replay
import time
from collections.abc import Callable
from typing import cast
@@ -97,6 +98,7 @@ def generate_final_report(
citation_mapping: CitationMapping,
user_identity: LLMUserIdentity | None,
saved_reasoning: str | None = None,
tool_processing_duration: float | None = None,
) -> bool:
"""Generate the final research report.
@@ -147,6 +149,7 @@ def generate_final_report(
user_identity=user_identity,
max_tokens=MAX_FINAL_REPORT_TOKENS,
is_deep_research=True,
tool_processing_duration=tool_processing_duration,
)
# Save citation mapping to state_container so citations are persisted
@@ -200,6 +203,9 @@ def run_deep_research_llm_loop(
initialize_litellm()
# Track processing start time for tool duration calculation
processing_start_time = time.monotonic()
available_tokens = llm.config.max_input_tokens
llm_step_result: LlmStepResult | None = None
@@ -240,6 +246,9 @@ def run_deep_research_llm_loop(
last_n_user_messages=MAX_USER_MESSAGES_FOR_CONTEXT,
)
# Calculate tool processing duration for clarification step
# (used if the LLM emits a clarification question instead of calling tools)
clarification_tool_duration = time.monotonic() - processing_start_time
llm_step_result, _ = run_llm_step(
emitter=emitter,
history=truncated_message_history,
@@ -254,6 +263,7 @@ def run_deep_research_llm_loop(
final_documents=None,
user_identity=user_identity,
is_deep_research=True,
tool_processing_duration=clarification_tool_duration,
)
if not llm_step_result.tool_calls:
@@ -406,6 +416,8 @@ def run_deep_research_llm_loop(
turn_index=report_turn_index,
citation_mapping=citation_mapping,
user_identity=user_identity,
tool_processing_duration=time.monotonic()
- processing_start_time,
)
# Update final_turn_index: base + 1 for the report itself + 1 if reasoning occurred
final_turn_index = report_turn_index + (1 if report_reasoned else 0)
@@ -493,6 +505,8 @@ def run_deep_research_llm_loop(
turn_index=report_turn_index,
citation_mapping=citation_mapping,
user_identity=user_identity,
tool_processing_duration=time.monotonic()
- processing_start_time,
)
final_turn_index = report_turn_index + (1 if report_reasoned else 0)
break
@@ -513,6 +527,8 @@ def run_deep_research_llm_loop(
citation_mapping=citation_mapping,
user_identity=user_identity,
saved_reasoning=most_recent_reasoning,
tool_processing_duration=time.monotonic()
- processing_start_time,
)
final_turn_index = report_turn_index + (1 if report_reasoned else 0)
break
@@ -574,6 +590,8 @@ def run_deep_research_llm_loop(
turn_index=report_turn_index,
citation_mapping=citation_mapping,
user_identity=user_identity,
tool_processing_duration=time.monotonic()
- processing_start_time,
)
final_turn_index = report_turn_index + (
1 if report_reasoned else 0

View File

@@ -313,6 +313,7 @@ class ChatMessageDetail(BaseModel):
files: list[FileDescriptor]
error: str | None = None
current_feedback: str | None = None # "like" | "dislike" | null
processing_duration_seconds: float | None = None
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore

View File

@@ -105,6 +105,7 @@ class AgentResponseStart(BaseObj):
type: Literal["message_start"] = StreamingType.MESSAGE_START.value
final_documents: list[SearchDoc] | None = None
tool_processing_duration_seconds: float | None = None
# The stream of tokens for the final response

View File

@@ -10,7 +10,7 @@ const SvgCircle = ({ size, ...props }: IconProps) => (
stroke="currentColor"
{...props}
>
<circle cx="8" cy="8" r="6" strokeWidth={1.5} />
<circle cx="8" cy="8" r="4" strokeWidth={1.5} />
</svg>
);
export default SvgCircle;

45
web/package-lock.json generated
View File

@@ -71,6 +71,7 @@
"react-loader-spinner": "^8.0.0",
"react-markdown": "^9.0.1",
"react-select": "^5.8.0",
"react-truncate-markup": "^5.1.2",
"recharts": "^2.13.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
@@ -8441,6 +8442,11 @@
"version": "1.0.1",
"license": "MIT"
},
"node_modules/computed-style": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/computed-style/-/computed-style-0.1.4.tgz",
"integrity": "sha512-WpAmaKbMNmS3OProfHIdJiNleNJdgUrJfbKArXua28QF7+0CoZjlLn0lp6vlc+dl5r2/X9GQiQRQQU4BzSa69w=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
@@ -12706,6 +12712,18 @@
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/line-height": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/line-height/-/line-height-0.3.1.tgz",
"integrity": "sha512-YExecgqPwnp5gplD2+Y8e8A5+jKpr25+DzMbFdI1/1UAr0FJrTFv4VkHLf8/6B590i1wUPJWMKKldkd/bdQ//w==",
"license": "MIT",
"dependencies": {
"computed-style": "~0.1.3"
},
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"license": "MIT"
@@ -15725,6 +15743,27 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-truncate-markup": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-truncate-markup/-/react-truncate-markup-5.1.2.tgz",
"integrity": "sha512-eEq6T8Rs+wz98cRYzQECGFNBfXwRYraLg/kz52f6DRBKmzxqB+GYLeDkVe/zrC+2vh5AEwM6nSYFvDWEBljd0w==",
"license": "Apache-2.0",
"dependencies": {
"line-height": "0.3.1",
"memoize-one": "^5.1.1",
"prop-types": "^15.6.0",
"resize-observer-polyfill": "1.5.x"
},
"peerDependencies": {
"react": ">=16.3"
}
},
"node_modules/react-truncate-markup/node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
"license": "MIT",
@@ -16048,6 +16087,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.8",
"license": "MIT",

View File

@@ -87,6 +87,7 @@
"react-loader-spinner": "^8.0.0",
"react-markdown": "^9.0.1",
"react-select": "^5.8.0",
"react-truncate-markup": "^5.1.2",
"recharts": "^2.13.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",

View File

@@ -18,7 +18,7 @@ import CardSection from "@/components/admin/CardSection";
import { useRouter } from "next/navigation";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
import { SEARCH_TOOL_ID } from "@/app/chat/components/tools/constants";
import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants";
import { SlackChannelConfigFormFields } from "./SlackChannelConfigFormFields";
export const SlackChannelConfigCreationForm = ({

View File

@@ -4,10 +4,10 @@ import { cn, noProp } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { useCallback, useMemo, useState, useEffect } from "react";
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
import ShareChatSessionModal from "@/app/app/components/modal/ShareChatSessionModal";
import IconButton from "@/refresh-components/buttons/IconButton";
import LineItem from "@/refresh-components/buttons/LineItem";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import { useProjectsContext } from "@/app/app/projects/ProjectsContext";
import useChatSessions from "@/hooks/useChatSessions";
import { usePopup } from "@/components/admin/connectors/Popup";
import {
@@ -16,7 +16,7 @@ import {
showErrorNotification,
} from "@/sections/sidebar/sidebarUtils";
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
import { deleteChatSession } from "@/app/chat/services/lib";
import { deleteChatSession } from "@/app/app/services/lib";
import { useRouter } from "next/navigation";
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";

View File

@@ -5,10 +5,10 @@ import { HealthCheckBanner } from "@/components/health/healthcheck";
import {
personaIncludesRetrieval,
getAvailableContextTokens,
} from "@/app/chat/services/lib";
} from "@/app/app/services/lib";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
@@ -17,15 +17,15 @@ import { useSettingsContext } from "@/components/settings/SettingsProvider";
import Dropzone from "react-dropzone";
import ChatInputBar, {
ChatInputBarHandle,
} from "@/app/chat/components/input/ChatInputBar";
} from "@/app/app/components/input/ChatInputBar";
import useChatSessions from "@/hooks/useChatSessions";
import useCCPairs from "@/hooks/useCCPairs";
import { useTags } from "@/lib/hooks/useTags";
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
import { useAgents } from "@/hooks/useAgents";
import { ChatPopup } from "@/app/chat/components/ChatPopup";
import { ChatPopup } from "@/app/app/components/ChatPopup";
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
import { SEARCH_TOOL_ID } from "@/app/chat/components/tools/constants";
import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants";
import { useUser } from "@/components/user/UserProvider";
import NoAssistantModal from "@/components/modals/NoAssistantModal";
import TextView from "@/components/chat/TextView";
@@ -36,33 +36,33 @@ import { getSourceMetadata } from "@/lib/sources";
import { SourceMetadata } from "@/lib/search/interfaces";
import { FederatedConnectorDetail, UserRole, ValidSources } from "@/lib/types";
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
import { useChatController } from "@/app/chat/hooks/useChatController";
import { useAssistantController } from "@/app/chat/hooks/useAssistantController";
import { useChatSessionController } from "@/app/chat/hooks/useChatSessionController";
import { useDeepResearchToggle } from "@/app/chat/hooks/useDeepResearchToggle";
import { useIsDefaultAgent } from "@/app/chat/hooks/useIsDefaultAgent";
import { useChatController } from "@/app/app/hooks/useChatController";
import { useAssistantController } from "@/app/app/hooks/useAssistantController";
import { useChatSessionController } from "@/app/app/hooks/useChatSessionController";
import { useDeepResearchToggle } from "@/app/app/hooks/useDeepResearchToggle";
import { useIsDefaultAgent } from "@/app/app/hooks/useIsDefaultAgent";
import {
useChatSessionStore,
useCurrentMessageHistory,
} from "@/app/chat/stores/useChatSessionStore";
} from "@/app/app/stores/useChatSessionStore";
import {
useCurrentChatState,
useIsReady,
useDocumentSidebarVisible,
} from "@/app/chat/stores/useChatSessionStore";
} from "@/app/app/stores/useChatSessionStore";
import FederatedOAuthModal from "@/components/chat/FederatedOAuthModal";
import ChatScrollContainer, {
ChatScrollContainerHandle,
} from "@/components/chat/ChatScrollContainer";
import MessageList from "@/components/chat/MessageList";
import WelcomeMessage from "@/app/chat/components/WelcomeMessage";
import ProjectContextPanel from "@/app/chat/components/projects/ProjectContextPanel";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
import ProjectContextPanel from "@/app/app/components/projects/ProjectContextPanel";
import { useProjectsContext } from "@/app/app/projects/ProjectsContext";
import {
getProjectTokenCount,
getMaxSelectedDocumentTokens,
} from "@/app/chat/projects/projectsService";
import ProjectChatSessionList from "@/app/chat/components/projects/ProjectChatSessionList";
} from "@/app/app/projects/projectsService";
import ProjectChatSessionList from "@/app/app/components/projects/ProjectChatSessionList";
import { cn } from "@/lib/utils";
import Suggestions from "@/sections/Suggestions";
import OnboardingFlow from "@/refresh-components/onboarding/OnboardingFlow";
@@ -70,7 +70,7 @@ import { OnboardingStep } from "@/refresh-components/onboarding/types";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import * as AppLayouts from "@/layouts/app-layouts";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
import ChatHeader from "@/app/chat/components/ChatHeader";
import ChatHeader from "@/app/app/components/ChatHeader";
import IconButton from "@/refresh-components/buttons/IconButton";
import Spacer from "@/refresh-components/Spacer";
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";

View File

@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { FileDescriptor } from "@/app/chat/interfaces";
import { FileDescriptor } from "@/app/app/interfaces";
import { FiLoader, FiFileText } from "react-icons/fi";
import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import { buildImgUrl } from "@/app/chat/components/files/images/utils";
import { buildImgUrl } from "@/app/app/components/files/images/utils";
import { cn } from "@/lib/utils";
import * as Dialog from "@radix-ui/react-dialog";

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { FiDownload } from "react-icons/fi";
import { ImageShape } from "@/app/chat/services/streamingModels";
import { FullImageModal } from "@/app/chat/components/files/images/FullImageModal";
import { buildImgUrl } from "@/app/chat/components/files/images/utils";
import { ImageShape } from "@/app/app/services/streamingModels";
import { FullImageModal } from "@/app/app/components/files/images/FullImageModal";
import { buildImgUrl } from "@/app/app/components/files/images/utils";
import IconButton from "@/refresh-components/buttons/IconButton";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
import React, { useState, ReactNode, forwardRef } from "react";
import { Folder } from "./interfaces";
import { ChatSession } from "@/app/chat/interfaces";
import { ChatSession } from "@/app/app/interfaces";
import { Caret } from "@/components/icons/icons";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,4 @@
import { ChatSession } from "@/app/chat/interfaces";
import { ChatSession } from "@/app/app/interfaces";
export interface Folder {
folder_id?: number;

View File

@@ -11,24 +11,24 @@ import React, {
import LineItem from "@/refresh-components/buttons/LineItem";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import LLMPopover from "@/refresh-components/popovers/LLMPopover";
import { InputPrompt } from "@/app/chat/interfaces";
import { InputPrompt } from "@/app/app/interfaces";
import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
import useFilter from "@/hooks/useFilter";
import useCCPairs from "@/hooks/useCCPairs";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import { ChatState } from "@/app/chat/interfaces";
import { ChatState } from "@/app/app/interfaces";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import { getFormattedDateRangeString } from "@/lib/dateUtils";
import { truncateString, cn } from "@/lib/utils";
import { useUser } from "@/components/user/UserProvider";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import { FileCard } from "@/app/chat/components/input/FileCard";
import { useProjectsContext } from "@/app/app/projects/ProjectsContext";
import { FileCard } from "@/app/app/components/input/FileCard";
import {
ProjectFile,
UserFileStatus,
} from "@/app/chat/projects/projectsService";
} from "@/app/app/projects/projectsService";
import IconButton from "@/refresh-components/buttons/IconButton";
import FilePickerPopover from "@/refresh-components/popovers/FilePickerPopover";
import ActionsPopover from "@/refresh-components/popovers/ActionsPopover";
@@ -36,7 +36,7 @@ import SelectButton from "@/refresh-components/buttons/SelectButton";
import {
getIconForAction,
hasSearchToolsAvailable,
} from "@/app/chat/services/actionUtils";
} from "@/app/app/services/actionUtils";
import {
SvgArrowUp,
SvgCalendar,

View File

@@ -1,8 +1,8 @@
"use client";
import React, { useMemo } from "react";
import type { ProjectFile } from "@/app/chat/projects/projectsService";
import { UserFileStatus } from "@/app/chat/projects/projectsService";
import type { ProjectFile } from "@/app/app/projects/projectsService";
import { UserFileStatus } from "@/app/app/projects/projectsService";
import Text from "@/refresh-components/texts/Text";
import Truncated from "@/refresh-components/texts/Truncated";
import { cn, isImageFile } from "@/lib/utils";

View File

@@ -4,8 +4,8 @@ import { useState } from "react";
import Button from "@/refresh-components/buttons/Button";
import { Callout } from "@/components/ui/callout";
import Text from "@/components/ui/text";
import { ChatSession, ChatSessionSharedStatus } from "@/app/chat/interfaces";
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
import { ChatSession, ChatSessionSharedStatus } from "@/app/app/interfaces";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
import { usePopup } from "@/components/admin/connectors/Popup";
import { structureValue } from "@/lib/llm/utils";
import { LlmDescriptor, useLlmManager } from "@/lib/hooks";
@@ -14,10 +14,10 @@ import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
import { cn } from "@/lib/utils";
import { useCurrentAgent } from "@/hooks/useAgents";
import { useSearchParams } from "next/navigation";
import { useChatSessionStore } from "@/app/chat/stores/useChatSessionStore";
import { useChatSessionStore } from "@/app/app/stores/useChatSessionStore";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { copyAll } from "@/app/chat/message/copyingUtils";
import { copyAll } from "@/app/app/message/copyingUtils";
import { SvgCopy, SvgShare } from "@opal/icons";
function buildShareLink(chatSessionId: string) {

View File

@@ -4,7 +4,7 @@ import React, { useMemo } from "react";
import Link from "next/link";
import { ChatSessionMorePopup } from "@/components/sidebar/ChatSessionMorePopup";
import { useProjectsContext } from "../../projects/ProjectsContext";
import { ChatSession } from "@/app/chat/interfaces";
import { ChatSession } from "@/app/app/interfaces";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import { useAgents } from "@/hooks/useAgents";
import { formatRelativeTime } from "./project_utils";

View File

@@ -18,7 +18,7 @@ import CreateButton from "@/refresh-components/buttons/CreateButton";
import { FileCard } from "../input/FileCard";
import { hasNonImageFiles } from "@/lib/utils";
import IconButton from "@/refresh-components/buttons/IconButton";
import { FileCardSkeleton } from "@/app/chat/components/input/FileCard";
import { FileCardSkeleton } from "@/app/app/components/input/FileCard";
import ButtonRenaming from "@/refresh-components/buttons/ButtonRenaming";
import { UserFileStatus } from "../../projects/projectsService";
import { SvgAddLines, SvgEdit, SvgFiles, SvgFolderOpen } from "@opal/icons";

View File

@@ -69,7 +69,7 @@ import {
useCurrentMessageHistory,
} from "../stores/useChatSessionStore";
import { Packet, MessageStart, PacketType } from "../services/streamingModels";
import { useAssistantPreferences } from "@/app/chat/hooks/useAssistantPreferences";
import { useAssistantPreferences } from "@/app/app/hooks/useAssistantPreferences";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import { ProjectFile, useProjectsContext } from "../projects/ProjectsContext";
import { useAppParams } from "@/hooks/appNavigation";
@@ -173,6 +173,9 @@ export function useChatController({
(state) => state.setAbortController
);
const setIsReady = useChatSessionStore((state) => state.setIsReady);
const setStreamingStartTime = useChatSessionStore(
(state) => state.setStreamingStartTime
);
// Use custom hooks for accessing store data
const currentMessageTree = useCurrentMessageTree();
@@ -342,6 +345,7 @@ export function useChatController({
// Update chat state to input immediately for good UX
// The stream will close naturally when the backend sends the STOP packet
setStreamingStartTime(currentSession, null);
updateChatStateAction(currentSession, "input");
}, [currentMessageHistory, currentMessageTree]);
@@ -734,6 +738,14 @@ export function useChatController({
// We've processed initial packets and are starting to stream content.
// Transition from 'loading' to 'streaming'.
updateChatStateAction(frozenSessionId, "streaming");
// Only set start time once (guard prevents reset on each packet)
// Use getState() to avoid stale closure - sessions captured at render time becomes stale in async loop
if (
!useChatSessionStore.getState().sessions.get(frozenSessionId)
?.streamingStartTime
) {
setStreamingStartTime(frozenSessionId, Date.now());
}
if ((packet as MessageResponseIDInfo).user_message_id) {
newUserMessageId = (packet as MessageResponseIDInfo)
@@ -859,8 +871,17 @@ export function useChatController({
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
packetsVersion: packetsVersion,
packetCount: packets.length,
processingDurationSeconds:
finalMessage?.processing_duration_seconds ??
(() => {
const startTime = useChatSessionStore
.getState()
.getStreamingStartTime(frozenSessionId);
return startTime
? Math.floor((Date.now() - startTime) / 1000)
: undefined;
})(),
},
],
// Pass the latest map state
@@ -910,6 +931,7 @@ export function useChatController({
}
resetRegenerationState(frozenSessionId);
setStreamingStartTime(frozenSessionId, null);
updateChatStateAction(frozenSessionId, "input");
// Name the chat now that we have the first AI response (navigation already happened before streaming)

View File

@@ -139,8 +139,6 @@ export interface Message {
// new gen
packets: Packet[];
// Version counter for efficient memo comparison (increments with each packet)
packetsVersion?: number;
packetCount?: number; // Tracks packet count for React memo comparison (avoids reading from mutated array)
// cached values for easy access
@@ -149,6 +147,9 @@ export interface Message {
// feedback state
currentFeedback?: FeedbackType | null;
// Duration in seconds for processing this message (assistant messages only)
processingDurationSeconds?: number;
}
export interface BackendChatSession {
@@ -198,6 +199,8 @@ export interface BackendMessage {
files: FileDescriptor[];
tool_call: ToolCallFinalResult | null;
current_feedback: string | null;
// Duration in seconds for processing this message (assistant messages only)
processing_duration_seconds?: number;
sub_questions: SubQuestionDetail[];
// Keeping existing properties

View File

@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { ChatFileType, FileDescriptor } from "@/app/chat/interfaces";
import { ChatFileType, FileDescriptor } from "@/app/app/interfaces";
import Attachment from "@/refresh-components/Attachment";
import { InMessageImage } from "@/app/chat/components/files/images/InMessageImage";
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
import CsvContent from "@/components/tools/CSVContent";
import TextView from "@/components/chat/TextView";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";

View File

@@ -1,9 +1,9 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { FileDescriptor } from "@/app/chat/interfaces";
import { FileDescriptor } from "@/app/app/interfaces";
import "katex/dist/katex.min.css";
import MessageSwitcher from "@/app/chat/message/MessageSwitcher";
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import IconButton from "@/refresh-components/buttons/IconButton";

View File

@@ -1,10 +1,9 @@
import {
Citation,
QuestionCardProps,
DocumentCardProps,
} from "@/components/search/results/Citation";
import { LoadedOnyxDocument, OnyxDocument } from "@/lib/search/interfaces";
import React, { memo, JSX } from "react";
import React, { memo, JSX, useMemo, useCallback } from "react";
import { SourceIcon } from "@/components/SourceIcon";
import { WebResultIcon } from "@/components/WebResultIcon";
import { SubQuestionDetail, CitationMap } from "../interfaces";
@@ -13,6 +12,13 @@ import { ProjectFile } from "../projects/projectsService";
import { BlinkingDot } from "./BlinkingDot";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import SourceTag from "@/refresh-components/buttons/source-tag/SourceTag";
import {
documentToSourceInfo,
questionToSourceInfo,
getDisplayNameForSource,
} from "@/refresh-components/buttons/source-tag/sourceTagUtils";
import { openDocument } from "@/lib/search/utils";
export const MemoizedAnchor = memo(
({
@@ -124,38 +130,48 @@ export const MemoizedLink = memo(
[key: string]: any;
}) => {
const value = rest.children;
const questionCardProps: QuestionCardProps | undefined =
question && openQuestion
? {
question: question,
openQuestion: openQuestion,
}
: undefined;
const documentCardProps: DocumentCardProps | undefined =
document && updatePresentingDocument
? {
url: document.link,
document: document as LoadedOnyxDocument,
updatePresentingDocument: updatePresentingDocument!,
}
: undefined;
// Convert document to SourceInfo for SourceTag
const documentSourceInfo = useMemo(() => {
if (!document) return null;
return documentToSourceInfo(document as OnyxDocument);
}, [document]);
// Convert question to SourceInfo for SourceTag
const questionSourceInfo = useMemo(() => {
if (!question) return null;
return questionToSourceInfo(question, question.level_question_num);
}, [question]);
// Handle click on SourceTag
const handleSourceClick = useCallback(() => {
if (document && updatePresentingDocument) {
openDocument(document as OnyxDocument, updatePresentingDocument);
} else if (question && openQuestion) {
openQuestion(question);
}
}, [document, updatePresentingDocument, question, openQuestion]);
if (value?.toString().startsWith("*")) {
return <BlinkingDot addMargin />;
} else if (value?.toString().startsWith("[")) {
const sourceInfo = documentSourceInfo || questionSourceInfo;
if (!sourceInfo) {
return <>{rest.children}</>;
}
const displayName = document
? getDisplayNameForSource(document as OnyxDocument)
: question?.question || "Question";
return (
<>
{documentCardProps ? (
<Citation document_info={documentCardProps}>
{rest.children}
</Citation>
) : (
<Citation question_info={questionCardProps}>
{rest.children}
</Citation>
)}
</>
<SourceTag
inlineCitation
displayName={displayName}
sources={[sourceInfo]}
onSourceClick={handleSourceClick}
showDetailsCard
/>
);
}

View File

@@ -0,0 +1,253 @@
import React, { useRef, RefObject, useMemo } from "react";
import { Packet, StopReason } from "@/app/app/services/streamingModels";
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
import { FeedbackType } from "@/app/app/interfaces";
import { handleCopy } from "@/app/app/message/copyingUtils";
import { useMessageSwitching } from "@/app/app/message/messageComponents/hooks/useMessageSwitching";
import { RendererComponent } from "@/app/app/message/messageComponents/renderMessageComponent";
import { usePacketProcessor } from "@/app/app/message/messageComponents/timeline/hooks/usePacketProcessor";
import { usePacedTurnGroups } from "@/app/app/message/messageComponents/timeline/hooks/usePacedTurnGroups";
import MessageToolbar from "@/app/app/message/messageComponents/MessageToolbar";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import { Message } from "@/app/app/interfaces";
import Text from "@/refresh-components/texts/Text";
import { AgentTimeline } from "@/app/app/message/messageComponents/timeline/AgentTimeline";
// Type for the regeneration factory function passed from ChatUI
export type RegenerationFactory = (regenerationRequest: {
messageId: number;
parentMessage: Message;
forceSearch?: boolean;
}) => (modelOverride: LlmDescriptor) => Promise<void>;
export interface AgentMessageProps {
rawPackets: Packet[];
packetCount?: number; // Tracked separately for React memo comparison (avoids reading from mutated array)
chatState: FullChatState;
nodeId: number;
messageId?: number;
currentFeedback?: FeedbackType | null;
llmManager: LlmManager | null;
otherMessagesCanSwitchTo?: number[];
onMessageSelection?: (nodeId: number) => void;
// Stable regeneration callback - takes (parentMessage) and returns a function that takes (modelOverride)
onRegenerate?: RegenerationFactory;
// Parent message needed to construct regeneration request
parentMessage?: Message | null;
// Duration in seconds for processing this message (assistant messages only)
processingDurationSeconds?: number;
}
// TODO: Consider more robust comparisons:
// - `chatState.docs`, `chatState.citations`, and `otherMessagesCanSwitchTo` use
// reference equality. Shallow array/object comparison would be more robust if
// these are recreated with the same values.
function arePropsEqual(
prev: AgentMessageProps,
next: AgentMessageProps
): boolean {
return (
prev.nodeId === next.nodeId &&
prev.messageId === next.messageId &&
prev.currentFeedback === next.currentFeedback &&
// Compare packetCount (primitive) instead of rawPackets.length
// The array is mutated in place, so reading .length from prev and next would return same value
prev.packetCount === next.packetCount &&
prev.chatState.assistant?.id === next.chatState.assistant?.id &&
prev.chatState.docs === next.chatState.docs &&
prev.chatState.citations === next.chatState.citations &&
prev.chatState.overriddenModel === next.chatState.overriddenModel &&
prev.chatState.researchType === next.chatState.researchType &&
prev.otherMessagesCanSwitchTo === next.otherMessagesCanSwitchTo &&
prev.onRegenerate === next.onRegenerate &&
prev.parentMessage?.messageId === next.parentMessage?.messageId &&
prev.llmManager?.isLoadingProviders ===
next.llmManager?.isLoadingProviders &&
prev.processingDurationSeconds === next.processingDurationSeconds
// Skip: chatState.regenerate, chatState.setPresentingDocument,
// most of llmManager, onMessageSelection (function/object props)
);
}
const AgentMessage = React.memo(function AgentMessage({
rawPackets,
chatState,
nodeId,
messageId,
currentFeedback,
llmManager,
otherMessagesCanSwitchTo,
onMessageSelection,
onRegenerate,
parentMessage,
processingDurationSeconds,
}: AgentMessageProps) {
const markdownRef = useRef<HTMLDivElement>(null);
const finalAnswerRef = useRef<HTMLDivElement>(null);
// Process streaming packets: returns data and callbacks
// Hook handles all state internally, exposes clean API
const {
citations,
citationMap,
documentMap,
toolGroups,
toolTurnGroups,
displayGroups,
hasSteps,
stopPacketSeen,
stopReason,
isGeneratingImage,
generatedImageCount,
isComplete,
onRenderComplete,
finalAnswerComing,
toolProcessingDuration,
} = usePacketProcessor(rawPackets, nodeId);
// Apply pacing delays between different tool types for smoother visual transitions
const { pacedTurnGroups, pacedDisplayGroups, pacedFinalAnswerComing } =
usePacedTurnGroups(
toolTurnGroups,
displayGroups,
stopPacketSeen,
nodeId,
finalAnswerComing
);
// Memoize merged citations separately to avoid creating new object when neither source changed
const mergedCitations = useMemo(
() => ({
...chatState.citations,
...citationMap,
}),
[chatState.citations, citationMap]
);
// Create a chatState that uses streaming citations for immediate rendering
// This merges the prop citations with streaming citations, preferring streaming ones
// Memoized with granular dependencies to prevent cascading re-renders
// Note: chatState object is recreated upstream on every render, so we depend on
// individual fields instead of the whole object for proper memoization
const effectiveChatState = useMemo<FullChatState>(
() => ({
...chatState,
citations: mergedCitations,
}),
[
chatState.assistant,
chatState.docs,
chatState.setPresentingDocument,
chatState.overriddenModel,
chatState.researchType,
mergedCitations,
]
);
// Message switching logic
const {
currentMessageInd,
includeMessageSwitcher,
getPreviousMessage,
getNextMessage,
} = useMessageSwitching({
nodeId,
otherMessagesCanSwitchTo,
onMessageSelection,
});
return (
<div
className="pb-5 md:pt-5 flex flex-col gap-3"
data-testid={isComplete ? "onyx-ai-message" : undefined}
>
{/* Row 1: Two-column layout for tool steps */}
<AgentTimeline
turnGroups={pacedTurnGroups}
chatState={effectiveChatState}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
hasDisplayContent={pacedDisplayGroups.length > 0}
processingDurationSeconds={processingDurationSeconds}
isGeneratingImage={isGeneratingImage}
generatedImageCount={generatedImageCount}
finalAnswerComing={pacedFinalAnswerComing}
toolProcessingDuration={toolProcessingDuration}
/>
{/* Row 2: Display content + MessageToolbar */}
<div
ref={markdownRef}
className="overflow-x-visible focus:outline-none select-text cursor-text px-3"
onCopy={(e) => {
if (markdownRef.current) {
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
}
}}
>
{pacedDisplayGroups.length > 0 && (
<div ref={finalAnswerRef}>
{pacedDisplayGroups.map((displayGroup, index) => (
<RendererComponent
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
packets={displayGroup.packets}
chatState={effectiveChatState}
onComplete={() => {
// Only mark complete on the last display group
// Hook handles the finalAnswerComing check internally
if (index === pacedDisplayGroups.length - 1) {
onRenderComplete();
}
}}
animate={false}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{(results) => (
<>
{results.map((r, i) => (
<div key={i}>{r.content}</div>
))}
</>
)}
</RendererComponent>
))}
</div>
)}
{/* Show stopped message when user cancelled and no display content */}
{pacedDisplayGroups.length === 0 &&
stopReason === StopReason.USER_CANCELLED && (
<Text as="p" secondaryBody text04>
User has stopped generation
</Text>
)}
</div>
{/* Feedback buttons - only show when streaming and rendering complete */}
{isComplete && (
<MessageToolbar
nodeId={nodeId}
messageId={messageId}
includeMessageSwitcher={includeMessageSwitcher}
currentMessageInd={currentMessageInd}
otherMessagesCanSwitchTo={otherMessagesCanSwitchTo}
getPreviousMessage={getPreviousMessage}
getNextMessage={getNextMessage}
onMessageSelection={onMessageSelection}
rawPackets={rawPackets}
finalAnswerRef={finalAnswerRef}
currentFeedback={currentFeedback}
onRegenerate={onRegenerate}
parentMessage={parentMessage}
llmManager={llmManager}
currentModelName={chatState.overriddenModel}
citations={citations}
documentMap={documentMap}
/>
)}
</div>
);
}, arePropsEqual);
export default AgentMessage;

View File

@@ -0,0 +1,323 @@
import React, { RefObject, useState, useCallback, useMemo } from "react";
import { Packet, StreamingCitation } from "@/app/app/services/streamingModels";
import { FeedbackType } from "@/app/app/interfaces";
import { OnyxDocument } from "@/lib/search/interfaces";
import { TooltipGroup } from "@/components/tooltip/CustomTooltip";
import {
useChatSessionStore,
useDocumentSidebarVisible,
useSelectedNodeForDocDisplay,
} from "@/app/app/stores/useChatSessionStore";
import {
handleCopy,
convertMarkdownTablesToTsv,
} from "@/app/app/message/copyingUtils";
import { getTextContent } from "@/app/app/services/packetUtils";
import { removeThinkingTokens } from "@/app/app/services/thinkingTokens";
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
import SourceTag from "@/refresh-components/buttons/source-tag/SourceTag";
import { citationsToSourceInfoArray } from "@/refresh-components/buttons/source-tag/sourceTagUtils";
import IconButton from "@/refresh-components/buttons/IconButton";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import LLMPopover from "@/refresh-components/popovers/LLMPopover";
import { parseLlmDescriptor } from "@/lib/llm/utils";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import { Message } from "@/app/app/interfaces";
import { SvgThumbsDown, SvgThumbsUp } from "@opal/icons";
import { RegenerationFactory } from "./AgentMessage";
import { usePopup } from "@/components/admin/connectors/Popup";
import useFeedbackController from "@/hooks/useFeedbackController";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import FeedbackModal, {
FeedbackModalProps,
} from "@/sections/modals/FeedbackModal";
// Wrapper component for SourceTag in toolbar to handle memoization
const SourcesTagWrapper = React.memo(function SourcesTagWrapper({
citations,
documentMap,
nodeId,
selectedMessageForDocDisplay,
documentSidebarVisible,
updateCurrentDocumentSidebarVisible,
updateCurrentSelectedNodeForDocDisplay,
}: {
citations: StreamingCitation[];
documentMap: Map<string, OnyxDocument>;
nodeId: number;
selectedMessageForDocDisplay: number | null;
documentSidebarVisible: boolean;
updateCurrentDocumentSidebarVisible: (visible: boolean) => void;
updateCurrentSelectedNodeForDocDisplay: (nodeId: number | null) => void;
}) {
// Convert citations to SourceInfo array
const sources = useMemo(
() => citationsToSourceInfoArray(citations, documentMap),
[citations, documentMap]
);
// Handle click to toggle sidebar
const handleSourceClick = useCallback(() => {
if (selectedMessageForDocDisplay === nodeId && documentSidebarVisible) {
updateCurrentDocumentSidebarVisible(false);
updateCurrentSelectedNodeForDocDisplay(null);
} else {
updateCurrentSelectedNodeForDocDisplay(nodeId);
updateCurrentDocumentSidebarVisible(true);
}
}, [
nodeId,
selectedMessageForDocDisplay,
documentSidebarVisible,
updateCurrentDocumentSidebarVisible,
updateCurrentSelectedNodeForDocDisplay,
]);
if (sources.length === 0) return null;
return (
<SourceTag
displayName="Sources"
sources={sources}
onSourceClick={handleSourceClick}
toggleSource
/>
);
});
export interface MessageToolbarProps {
// Message identification
nodeId: number;
messageId?: number;
// Message switching
includeMessageSwitcher: boolean;
currentMessageInd: number | null | undefined;
otherMessagesCanSwitchTo?: number[];
getPreviousMessage: () => number | undefined;
getNextMessage: () => number | undefined;
onMessageSelection?: (nodeId: number) => void;
// Copy functionality
rawPackets: Packet[];
finalAnswerRef: RefObject<HTMLDivElement | null>;
// Feedback
currentFeedback?: FeedbackType | null;
// Regeneration
onRegenerate?: RegenerationFactory;
parentMessage?: Message | null;
llmManager: LlmManager | null;
currentModelName?: string;
// Citations
citations: StreamingCitation[];
documentMap: Map<string, OnyxDocument>;
}
export default function MessageToolbar({
nodeId,
messageId,
includeMessageSwitcher,
currentMessageInd,
otherMessagesCanSwitchTo,
getPreviousMessage,
getNextMessage,
onMessageSelection,
rawPackets,
finalAnswerRef,
currentFeedback,
onRegenerate,
parentMessage,
llmManager,
currentModelName,
citations,
documentMap,
}: MessageToolbarProps) {
// Document sidebar state - managed internally to reduce prop drilling
const documentSidebarVisible = useDocumentSidebarVisible();
const selectedMessageForDocDisplay = useSelectedNodeForDocDisplay();
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
(state) => state.updateCurrentDocumentSidebarVisible
);
const updateCurrentSelectedNodeForDocDisplay = useChatSessionStore(
(state) => state.updateCurrentSelectedNodeForDocDisplay
);
// Feedback modal state and handlers
const { popup, setPopup } = usePopup();
const { handleFeedbackChange } = useFeedbackController({ setPopup });
const modal = useCreateModal();
const [feedbackModalProps, setFeedbackModalProps] =
useState<FeedbackModalProps | null>(null);
// Helper to check if feedback button should be in transient state
const isFeedbackTransient = useCallback(
(feedbackType: "like" | "dislike") => {
const hasCurrentFeedback = currentFeedback === feedbackType;
if (!modal.isOpen) return hasCurrentFeedback;
const isModalForThisFeedback =
feedbackModalProps?.feedbackType === feedbackType;
const isModalForThisMessage = feedbackModalProps?.messageId === messageId;
return (
hasCurrentFeedback || (isModalForThisFeedback && isModalForThisMessage)
);
},
[currentFeedback, modal.isOpen, feedbackModalProps, messageId]
);
// Handler for feedback button clicks with toggle logic
const handleFeedbackClick = useCallback(
async (clickedFeedback: "like" | "dislike") => {
if (!messageId) {
console.error("Cannot provide feedback - message has no messageId");
return;
}
// Toggle logic
if (currentFeedback === clickedFeedback) {
// Clicking same button - remove feedback
await handleFeedbackChange(messageId, null);
}
// Clicking like (will automatically clear dislike if it was active).
// Check if we need modal for positive feedback.
else if (clickedFeedback === "like") {
const predefinedOptions =
process.env.NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS;
if (predefinedOptions && predefinedOptions.trim()) {
// Open modal for positive feedback
setFeedbackModalProps({
feedbackType: "like",
messageId,
});
modal.toggle(true);
} else {
// No modal needed - just submit like (this replaces any existing feedback)
await handleFeedbackChange(messageId, "like");
}
}
// Clicking dislike (will automatically clear like if it was active).
// Always open modal for dislike.
else {
setFeedbackModalProps({
feedbackType: "dislike",
messageId,
});
modal.toggle(true);
}
},
[messageId, currentFeedback, handleFeedbackChange, modal]
);
return (
<>
{popup}
<modal.Provider>
<FeedbackModal {...feedbackModalProps!} />
</modal.Provider>
<div className="flex md:flex-row justify-between items-center w-full transition-transform duration-300 ease-in-out transform opacity-100">
<TooltipGroup>
<div className="flex items-center gap-x-0.5">
{includeMessageSwitcher && (
<div className="-mx-1">
<MessageSwitcher
currentPage={(currentMessageInd ?? 0) + 1}
totalPages={otherMessagesCanSwitchTo?.length || 0}
handlePrevious={() => {
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined && onMessageSelection) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
const nextMessage = getNextMessage();
if (nextMessage !== undefined && onMessageSelection) {
onMessageSelection(nextMessage);
}
}}
/>
</div>
)}
<CopyIconButton
getCopyText={() =>
convertMarkdownTablesToTsv(
removeThinkingTokens(getTextContent(rawPackets)) as string
)
}
getHtmlContent={() => finalAnswerRef.current?.innerHTML || ""}
tertiary
data-testid="AgentMessage/copy-button"
/>
<IconButton
icon={SvgThumbsUp}
onClick={() => handleFeedbackClick("like")}
tertiary
transient={isFeedbackTransient("like")}
tooltip={
currentFeedback === "like" ? "Remove Like" : "Good Response"
}
data-testid="AgentMessage/like-button"
/>
<IconButton
icon={SvgThumbsDown}
onClick={() => handleFeedbackClick("dislike")}
tertiary
transient={isFeedbackTransient("dislike")}
tooltip={
currentFeedback === "dislike"
? "Remove Dislike"
: "Bad Response"
}
data-testid="AgentMessage/dislike-button"
/>
{onRegenerate &&
messageId !== undefined &&
parentMessage &&
llmManager && (
<div data-testid="AgentMessage/regenerate">
<LLMPopover
llmManager={llmManager}
currentModelName={currentModelName}
onSelect={(modelName) => {
const llmDescriptor = parseLlmDescriptor(modelName);
const regenerator = onRegenerate({
messageId,
parentMessage,
});
regenerator(llmDescriptor);
}}
folded
/>
</div>
)}
{nodeId && (citations.length > 0 || documentMap.size > 0) && (
<SourcesTagWrapper
citations={citations}
documentMap={documentMap}
nodeId={nodeId}
selectedMessageForDocDisplay={selectedMessageForDocDisplay}
documentSidebarVisible={documentSidebarVisible}
updateCurrentDocumentSidebarVisible={
updateCurrentDocumentSidebarVisible
}
updateCurrentSelectedNodeForDocDisplay={
updateCurrentSelectedNodeForDocDisplay
}
/>
)}
</div>
</TooltipGroup>
</div>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Packet } from "@/app/chat/services/streamingModels";
import { Packet } from "@/app/app/services/streamingModels";
// Control the rate of packet streaming (packets per second)
const PACKET_DELAY_MS = 10;

View File

@@ -12,6 +12,7 @@ export enum RenderType {
HIGHLIGHT = "highlight",
FULL = "full",
COMPACT = "compact",
INLINE = "inline",
}
export interface FullChatState {
@@ -37,10 +38,13 @@ export interface RendererResult {
// e.g. ReasoningRenderer
expandedText?: JSX.Element;
// Whether this renderer supports compact mode (collapse button shown only when true)
supportsCompact?: boolean;
// Whether this renderer supports collapsible mode (collapse button shown only when true)
supportsCollapsible?: boolean;
}
// All renderers return an array of results (even single-step renderers return a 1-element array)
export type RendererOutput = RendererResult[];
export type MessageRenderer<
T extends Packet,
S extends Partial<FullChatState>,
@@ -54,5 +58,7 @@ export type MessageRenderer<
stopReason?: StopReason;
/** Whether this is the last step in the timeline (for connector line decisions) */
isLastStep?: boolean;
children: (result: RendererResult) => JSX.Element;
/** Hover state from parent */
isHover?: boolean;
children: (result: RendererOutput) => JSX.Element;
}>;

View File

@@ -6,13 +6,13 @@ import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import "@/app/chat/message/custom-code-styles.css";
import { FullChatState } from "@/app/chat/message/messageComponents/interfaces";
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
import {
MemoizedAnchor,
MemoizedParagraph,
} from "@/app/chat/message/MemoizedTextComponents";
import { extractCodeText, preprocessLaTeX } from "@/app/chat/message/codeUtils";
import { CodeBlock } from "@/app/chat/message/CodeBlock";
} from "@/app/app/message/MemoizedTextComponents";
import { extractCodeText, preprocessLaTeX } from "@/app/app/message/codeUtils";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { transformLinkUri, cn } from "@/lib/utils";
/**

View File

@@ -1,4 +1,4 @@
import React, { JSX } from "react";
import React, { JSX, memo } from "react";
import {
ChatPacket,
Packet,
@@ -11,16 +11,19 @@ import {
MessageRenderer,
RenderType,
RendererResult,
RendererOutput,
} from "./interfaces";
import { MessageTextRenderer } from "./renderers/MessageTextRenderer";
import { ImageToolRenderer } from "./renderers/ImageToolRenderer";
import { PythonToolRenderer } from "./renderers/PythonToolRenderer";
import { ReasoningRenderer } from "./renderers/ReasoningRenderer";
import { PythonToolRenderer } from "./timeline/renderers/code/PythonToolRenderer";
import { ReasoningRenderer } from "./timeline/renderers/reasoning/ReasoningRenderer";
import CustomToolRenderer from "./renderers/CustomToolRenderer";
import { FetchToolRenderer } from "./renderers/FetchToolRenderer";
import { DeepResearchPlanRenderer } from "./renderers/DeepResearchPlanRenderer";
import { ResearchAgentRenderer } from "./renderers/ResearchAgentRenderer";
import { SearchToolRenderer } from "./renderers/SearchToolRenderer";
import { FetchToolRenderer } from "./timeline/renderers/fetch/FetchToolRenderer";
import { DeepResearchPlanRenderer } from "./timeline/renderers/deepresearch/DeepResearchPlanRenderer";
import { ResearchAgentRenderer } from "./timeline/renderers/deepresearch/ResearchAgentRenderer";
import { WebSearchToolRenderer } from "./timeline/renderers/search/WebSearchToolRenderer";
import { InternalSearchToolRenderer } from "./timeline/renderers/search/InternalSearchToolRenderer";
import { SearchToolStart } from "../../services/streamingModels";
// Different types of chat packets using discriminated unions
export interface GroupedPackets {
@@ -35,8 +38,14 @@ function isChatPacket(packet: Packet): packet is ChatPacket {
);
}
function isSearchToolPacket(packet: Packet) {
return packet.obj.type === PacketType.SEARCH_TOOL_START;
function isWebSearchPacket(packet: Packet): boolean {
if (packet.obj.type !== PacketType.SEARCH_TOOL_START) return false;
return (packet.obj as SearchToolStart).is_internet_search === true;
}
function isInternalSearchPacket(packet: Packet): boolean {
if (packet.obj.type !== PacketType.SEARCH_TOOL_START) return false;
return (packet.obj as SearchToolStart).is_internet_search !== true;
}
function isImageToolPacket(packet: Packet) {
@@ -101,8 +110,11 @@ export function findRenderer(
}
// Standard tool checks
if (groupedPackets.packets.some((packet) => isSearchToolPacket(packet))) {
return SearchToolRenderer;
if (groupedPackets.packets.some((packet) => isWebSearchPacket(packet))) {
return WebSearchToolRenderer;
}
if (groupedPackets.packets.some((packet) => isInternalSearchPacket(packet))) {
return InternalSearchToolRenderer;
}
if (groupedPackets.packets.some((packet) => isImageToolPacket(packet))) {
return ImageToolRenderer;
@@ -122,8 +134,36 @@ export function findRenderer(
return null;
}
// Props interface for RendererComponent
interface RendererComponentProps {
packets: Packet[];
chatState: FullChatState;
onComplete: () => void;
animate: boolean;
stopPacketSeen: boolean;
stopReason?: StopReason;
useShortRenderer?: boolean;
children: (result: RendererOutput) => JSX.Element;
}
// Custom comparison to prevent unnecessary re-renders
function areRendererPropsEqual(
prev: RendererComponentProps,
next: RendererComponentProps
): boolean {
return (
prev.packets.length === next.packets.length &&
prev.stopPacketSeen === next.stopPacketSeen &&
prev.stopReason === next.stopReason &&
prev.animate === next.animate &&
prev.useShortRenderer === next.useShortRenderer &&
prev.chatState.assistant?.id === next.chatState.assistant?.id
// Skip: onComplete, children (function refs), chatState (memoized upstream)
);
}
// React component wrapper that directly uses renderer components
export function RendererComponent({
export const RendererComponent = memo(function RendererComponent({
packets,
chatState,
onComplete,
@@ -132,21 +172,12 @@ export function RendererComponent({
stopReason,
useShortRenderer = false,
children,
}: {
packets: Packet[];
chatState: FullChatState;
onComplete: () => void;
animate: boolean;
stopPacketSeen: boolean;
stopReason?: StopReason;
useShortRenderer?: boolean;
children: (result: RendererResult) => JSX.Element;
}) {
}: RendererComponentProps) {
const RendererFn = findRenderer({ packets });
const renderType = useShortRenderer ? RenderType.HIGHLIGHT : RenderType.FULL;
if (!RendererFn) {
return children({ icon: null, status: null, content: <></> });
return children([{ icon: null, status: null, content: <></> }]);
}
return (
@@ -159,7 +190,7 @@ export function RendererComponent({
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{children}
{(results: RendererOutput) => children(results)}
</RendererFn>
);
}
}, areRendererPropsEqual);

View File

@@ -69,67 +69,71 @@ export const CustomToolRenderer: MessageRenderer<CustomToolPacket, {}> = ({
const icon = FiTool;
if (renderType === RenderType.COMPACT) {
return children({
icon,
status: status,
supportsCompact: true,
content: (
<div className="text-sm text-muted-foreground">
{isRunning && `${toolName} running...`}
{isComplete && `${toolName} completed`}
</div>
),
});
return children([
{
icon,
status: status,
supportsCollapsible: true,
content: (
<div className="text-sm text-muted-foreground">
{isRunning && `${toolName} running...`}
{isComplete && `${toolName} completed`}
</div>
),
},
]);
}
return children({
icon,
status,
supportsCompact: true,
content: (
<div className="flex flex-col gap-3">
{/* File responses */}
{fileIds && fileIds.length > 0 && (
<div className="text-sm text-muted-foreground flex flex-col gap-2">
{fileIds.map((fid, idx) => (
<div key={fid} className="flex items-center gap-2 flex-wrap">
<span className="whitespace-nowrap">File {idx + 1}</span>
<a
href={buildImgUrl(fid)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
>
<FiExternalLink className="w-3 h-3" /> Open
</a>
<a
href={buildImgUrl(fid)}
download
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
>
<FiDownload className="w-3 h-3" /> Download
</a>
</div>
))}
</div>
)}
return children([
{
icon,
status,
supportsCollapsible: true,
content: (
<div className="flex flex-col gap-3">
{/* File responses */}
{fileIds && fileIds.length > 0 && (
<div className="text-sm text-muted-foreground flex flex-col gap-2">
{fileIds.map((fid, idx) => (
<div key={fid} className="flex items-center gap-2 flex-wrap">
<span className="whitespace-nowrap">File {idx + 1}</span>
<a
href={buildImgUrl(fid)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
>
<FiExternalLink className="w-3 h-3" /> Open
</a>
<a
href={buildImgUrl(fid)}
download
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
>
<FiDownload className="w-3 h-3" /> Download
</a>
</div>
))}
</div>
)}
{/* JSON/Text responses */}
{data !== undefined && data !== null && (
<div className="text-xs bg-gray-50 dark:bg-gray-800 p-3 rounded border max-h-96 overflow-y-auto font-mono whitespace-pre-wrap break-all">
{typeof data === "string" ? data : JSON.stringify(data, null, 2)}
</div>
)}
{/* JSON/Text responses */}
{data !== undefined && data !== null && (
<div className="text-xs bg-gray-50 dark:bg-gray-800 p-3 rounded border max-h-96 overflow-y-auto font-mono whitespace-pre-wrap break-all">
{typeof data === "string" ? data : JSON.stringify(data, null, 2)}
</div>
)}
{/* Show placeholder if no response data yet */}
{!fileIds && (data === undefined || data === null) && isRunning && (
<div className="text-xs text-gray-500 italic">
Waiting for response...
</div>
)}
</div>
),
});
{/* Show placeholder if no response data yet */}
{!fileIds && (data === undefined || data === null) && isRunning && (
<div className="text-xs text-gray-500 italic">
Waiting for response...
</div>
)}
</div>
),
},
]);
};
export default CustomToolRenderer;

View File

@@ -0,0 +1,208 @@
import React, { useEffect, useMemo } from "react";
import { SvgImage } from "@opal/icons";
import {
PacketType,
ImageGenerationToolPacket,
ImageGenerationToolStart,
ImageGenerationToolDelta,
SectionEnd,
} from "../../../services/streamingModels";
import { MessageRenderer, RenderType } from "../interfaces";
import { InMessageImage } from "../../../components/files/images/InMessageImage";
import GeneratingImageDisplay from "../../../components/tools/GeneratingImageDisplay";
// Helper function to construct current image state
function constructCurrentImageState(packets: ImageGenerationToolPacket[]) {
const imageStart = packets.find(
(packet) => packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START
)?.obj as ImageGenerationToolStart | null;
const imageDeltas = packets
.filter(
(packet) => packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_DELTA
)
.map((packet) => packet.obj as ImageGenerationToolDelta);
const imageEnd = packets.find(
(packet) =>
packet.obj.type === PacketType.SECTION_END ||
packet.obj.type === PacketType.ERROR
)?.obj as SectionEnd | null;
const prompt = ""; // Image generation tools don't have a main description
const images = imageDeltas.flatMap((delta) => delta?.images || []);
const isGenerating = imageStart && !imageEnd;
const isComplete = imageStart && imageEnd;
return {
prompt,
images,
isGenerating,
isComplete,
error: false, // For now, we don't have error state in the packets
};
}
export const ImageToolRenderer: MessageRenderer<
ImageGenerationToolPacket,
{}
> = ({ packets, onComplete, renderType, children }) => {
const { prompt, images, isGenerating, isComplete, error } =
constructCurrentImageState(packets);
useEffect(() => {
if (isComplete) {
onComplete();
}
}, [isComplete]);
const status = useMemo(() => {
if (isComplete) {
return `Generated ${images.length} image${images.length > 1 ? "s" : ""}`;
}
if (isGenerating) {
return "Generating image...";
}
return null;
}, [isComplete, isGenerating, images.length]);
// Render based on renderType
if (renderType === RenderType.FULL) {
// Full rendering with title header and content below
// Loading state - when generating
if (isGenerating) {
return children([
{
icon: SvgImage,
status: "Generating images...",
supportsCollapsible: false,
content: (
<div className="flex flex-col">
<div>
<GeneratingImageDisplay isCompleted={false} />
</div>
</div>
),
},
]);
}
// Complete state - show images
if (isComplete) {
return children([
{
icon: SvgImage,
status: `Generated ${images.length} image${
images.length !== 1 ? "s" : ""
}`,
supportsCollapsible: false,
content: (
<div className="flex flex-col my-1">
{images.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{images.map((image, index: number) => (
<div
key={image.file_id || index}
className="transition-all group"
>
{image.file_id && (
<InMessageImage
fileId={image.file_id}
shape={image.shape}
/>
)}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-gray-500 dark:text-gray-400 ml-7">
<SvgImage className="w-6 h-6 mx-auto mb-2 opacity-50" />
<p className="text-sm">No images generated</p>
</div>
)}
</div>
),
},
]);
}
// Fallback (shouldn't happen in normal flow)
return children([
{
icon: SvgImage,
status: status,
supportsCollapsible: false,
content: <div></div>,
},
]);
}
// Highlight/Short rendering
if (isGenerating) {
return children([
{
icon: SvgImage,
status: "Generating image...",
supportsCollapsible: false,
content: (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-0.5">
<div className="w-1 h-1 bg-current rounded-full animate-pulse"></div>
<div
className="w-1 h-1 bg-current rounded-full animate-pulse"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="w-1 h-1 bg-current rounded-full animate-pulse"
style={{ animationDelay: "0.2s" }}
></div>
</div>
<span>Generating image...</span>
</div>
),
},
]);
}
if (error) {
return children([
{
icon: SvgImage,
status: "Image generation failed",
supportsCollapsible: false,
content: (
<div className="text-sm text-red-600 dark:text-red-400">
Image generation failed
</div>
),
},
]);
}
if (isComplete && images.length > 0) {
return children([
{
icon: SvgImage,
status: `Generated ${images.length} image${
images.length > 1 ? "s" : ""
}`,
supportsCollapsible: false,
content: (
<div className="text-sm text-muted-foreground">
Generated {images.length} image
{images.length > 1 ? "s" : ""}
</div>
),
},
]);
}
return children([
{
icon: SvgImage,
status: "Image generation",
supportsCollapsible: false,
content: (
<div className="text-sm text-muted-foreground">Image generation</div>
),
},
]);
};

View File

@@ -123,21 +123,23 @@ export const MessageTextRenderer: MessageRenderer<
const wasUserCancelled = stopReason === StopReason.USER_CANCELLED;
return children({
icon: null,
status: null,
content:
content.length > 0 || packets.length > 0 ? (
<>
{renderedContent}
{wasUserCancelled && (
<Text as="p" secondaryBody text04>
User has stopped generation
</Text>
)}
</>
) : (
<BlinkingDot addMargin />
),
});
return children([
{
icon: null,
status: null,
content:
content.length > 0 || packets.length > 0 ? (
<>
{renderedContent}
{wasUserCancelled && (
<Text as="p" secondaryBody text04>
User has stopped generation
</Text>
)}
</>
) : (
<BlinkingDot addMargin />
),
},
]);
};

View File

@@ -0,0 +1,382 @@
"use client";
import React, { useMemo, useCallback } from "react";
import { StopReason } from "@/app/app/services/streamingModels";
import { FullChatState, RenderType } from "../interfaces";
import { TurnGroup } from "./transformers";
import { cn } from "@/lib/utils";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import Text from "@/refresh-components/texts/Text";
import { useTimelineExpansion } from "@/app/app/message/messageComponents/timeline/hooks/useTimelineExpansion";
import { useTimelineMetrics } from "@/app/app/message/messageComponents/timeline/hooks/useTimelineMetrics";
import { useTimelineHeader } from "@/app/app/message/messageComponents/timeline/hooks/useTimelineHeader";
import {
useTimelineUIState,
TimelineUIState,
} from "@/app/app/message/messageComponents/timeline/hooks/useTimelineUIState";
import {
isResearchAgentPackets,
isSearchToolPackets,
stepSupportsCollapsedStreaming,
} from "@/app/app/message/messageComponents/timeline/packetHelpers";
import { StreamingHeader } from "@/app/app/message/messageComponents/timeline/headers/StreamingHeader";
import { CollapsedHeader } from "@/app/app/message/messageComponents/timeline/headers/CollapsedHeader";
import { ExpandedHeader } from "@/app/app/message/messageComponents/timeline/headers/ExpandedHeader";
import { StoppedHeader } from "@/app/app/message/messageComponents/timeline/headers/StoppedHeader";
import { ParallelStreamingHeader } from "@/app/app/message/messageComponents/timeline/headers/ParallelStreamingHeader";
import { useStreamingStartTime } from "@/app/app/stores/useChatSessionStore";
import { ExpandedTimelineContent } from "./ExpandedTimelineContent";
import { CollapsedStreamingContent } from "./CollapsedStreamingContent";
// =============================================================================
// Private Wrapper Components
// =============================================================================
interface TimelineContainerProps {
className?: string;
agent: FullChatState["assistant"];
headerContent?: React.ReactNode;
children?: React.ReactNode;
}
const TimelineContainer: React.FC<TimelineContainerProps> = ({
className,
agent,
headerContent,
children,
}) => (
<div className={cn("flex flex-col", className)}>
<div className="flex w-full h-9">
<div className="flex justify-center items-center size-9">
<AgentAvatar agent={agent} size={24} />
</div>
{headerContent}
</div>
{children}
</div>
);
// =============================================================================
// Main Component
// =============================================================================
export interface AgentTimelineProps {
/** Turn groups from usePacketProcessor */
turnGroups: TurnGroup[];
/** Chat state for rendering content */
chatState: FullChatState;
/** Whether the stop packet has been seen */
stopPacketSeen?: boolean;
/** Reason for stopping (if stopped) */
stopReason?: StopReason;
/** Whether final answer is coming (affects last connector) */
finalAnswerComing?: boolean;
/** Whether there is display content after timeline */
hasDisplayContent?: boolean;
/** Content to render after timeline (final message + toolbar) - slot pattern */
children?: React.ReactNode;
/** Whether the timeline is collapsible */
collapsible?: boolean;
/** Title of the button to toggle the timeline */
buttonTitle?: string;
/** Additional class names */
className?: string;
/** Test ID for e2e testing */
"data-testid"?: string;
/** Processing duration in seconds (for completed messages) */
processingDurationSeconds?: number;
/** Whether image generation is in progress */
isGeneratingImage?: boolean;
/** Number of images generated */
generatedImageCount?: number;
/** Tool processing duration from backend (via MESSAGE_START packet) */
toolProcessingDuration?: number;
}
/**
* Custom prop comparison for AgentTimeline memoization.
* Prevents unnecessary re-renders when parent renders but props haven't meaningfully changed.
*/
function areAgentTimelinePropsEqual(
prev: AgentTimelineProps,
next: AgentTimelineProps
): boolean {
return (
prev.turnGroups === next.turnGroups &&
prev.stopPacketSeen === next.stopPacketSeen &&
prev.stopReason === next.stopReason &&
prev.finalAnswerComing === next.finalAnswerComing &&
prev.hasDisplayContent === next.hasDisplayContent &&
prev.processingDurationSeconds === next.processingDurationSeconds &&
prev.collapsible === next.collapsible &&
prev.buttonTitle === next.buttonTitle &&
prev.className === next.className &&
prev.chatState.assistant?.id === next.chatState.assistant?.id &&
prev.isGeneratingImage === next.isGeneratingImage &&
prev.generatedImageCount === next.generatedImageCount &&
prev.toolProcessingDuration === next.toolProcessingDuration
);
}
export const AgentTimeline = React.memo(function AgentTimeline({
turnGroups,
chatState,
stopPacketSeen = false,
stopReason,
finalAnswerComing = false,
hasDisplayContent = false,
collapsible = true,
buttonTitle,
className,
"data-testid": testId,
processingDurationSeconds,
isGeneratingImage = false,
generatedImageCount = 0,
toolProcessingDuration,
}: AgentTimelineProps) {
// Header text and state flags
const { headerText, hasPackets, userStopped } = useTimelineHeader(
turnGroups,
stopReason,
isGeneratingImage
);
// Memoized metrics derived from turn groups
const {
totalSteps,
isSingleStep,
lastTurnGroup,
lastStep,
lastStepIsResearchAgent,
lastStepSupportsCollapsedStreaming,
} = useTimelineMetrics(turnGroups, userStopped);
// Check if last step is a search tool for INLINE render type
const lastStepIsSearchTool = useMemo(
() => lastStep && isSearchToolPackets(lastStep.packets),
[lastStep]
);
const { isExpanded, handleToggle, parallelActiveTab, setParallelActiveTab } =
useTimelineExpansion(stopPacketSeen, lastTurnGroup, hasDisplayContent);
// Streaming duration tracking
const streamingStartTime = useStreamingStartTime();
// Parallel step analysis for collapsed streaming view
const parallelActiveStep = useMemo(() => {
if (!lastTurnGroup?.isParallel) return null;
return (
lastTurnGroup.steps.find((s) => s.key === parallelActiveTab) ??
lastTurnGroup.steps[0]
);
}, [lastTurnGroup, parallelActiveTab]);
const parallelActiveStepSupportsCollapsedStreaming = useMemo(() => {
if (!parallelActiveStep) return false;
return stepSupportsCollapsedStreaming(parallelActiveStep.packets);
}, [parallelActiveStep]);
// Derive all UI state from inputs
const {
uiState,
showCollapsedCompact,
showCollapsedParallel,
showParallelTabs,
showDoneStep,
showStoppedStep,
hasDoneIndicator,
showTintedBackground,
showRoundedBottom,
} = useTimelineUIState({
stopPacketSeen,
hasPackets,
hasDisplayContent,
userStopped,
isExpanded,
lastTurnGroup,
lastStep,
lastStepSupportsCollapsedStreaming,
lastStepIsResearchAgent,
parallelActiveStepSupportsCollapsedStreaming,
isGeneratingImage,
finalAnswerComing,
});
// Determine render type override for collapsed streaming view
const collapsedRenderTypeOverride = useMemo(() => {
if (lastStepIsResearchAgent) return RenderType.HIGHLIGHT;
if (lastStepIsSearchTool) return RenderType.INLINE;
return undefined;
}, [lastStepIsResearchAgent, lastStepIsSearchTool]);
// Header selection based on UI state
const renderHeader = useCallback(() => {
switch (uiState) {
case TimelineUIState.STREAMING_PARALLEL:
// Only show parallel header when collapsed (showParallelTabs includes !isExpanded check)
if (showParallelTabs && lastTurnGroup) {
return (
<ParallelStreamingHeader
steps={lastTurnGroup.steps}
activeTab={parallelActiveTab}
onTabChange={setParallelActiveTab}
collapsible={collapsible}
isExpanded={isExpanded}
onToggle={handleToggle}
/>
);
}
// falls through to sequential header when expanded or no lastTurnGroup
case TimelineUIState.STREAMING_SEQUENTIAL:
return (
<StreamingHeader
headerText={headerText}
collapsible={collapsible}
buttonTitle={buttonTitle}
isExpanded={isExpanded}
onToggle={handleToggle}
streamingStartTime={streamingStartTime}
toolProcessingDuration={toolProcessingDuration}
/>
);
case TimelineUIState.STOPPED:
return (
<StoppedHeader
totalSteps={totalSteps}
collapsible={collapsible}
isExpanded={isExpanded}
onToggle={handleToggle}
/>
);
case TimelineUIState.COMPLETED_COLLAPSED:
return (
<CollapsedHeader
totalSteps={totalSteps}
collapsible={collapsible}
onToggle={handleToggle}
processingDurationSeconds={
toolProcessingDuration ?? processingDurationSeconds
}
generatedImageCount={generatedImageCount}
/>
);
case TimelineUIState.COMPLETED_EXPANDED:
return (
<ExpandedHeader
collapsible={collapsible}
onToggle={handleToggle}
processingDurationSeconds={
toolProcessingDuration ?? processingDurationSeconds
}
/>
);
default:
return null;
}
}, [
uiState,
showParallelTabs,
lastTurnGroup,
parallelActiveTab,
setParallelActiveTab,
collapsible,
isExpanded,
handleToggle,
headerText,
buttonTitle,
streamingStartTime,
totalSteps,
processingDurationSeconds,
generatedImageCount,
toolProcessingDuration,
]);
// Empty state: no packets, still streaming, and not stopped
if (uiState === TimelineUIState.EMPTY) {
return (
<TimelineContainer
className={className}
agent={chatState.assistant}
headerContent={
<div className="flex w-full h-full items-center px-2">
<Text
as="p"
mainUiAction
text03
className="animate-shimmer bg-[length:200%_100%] bg-[linear-gradient(90deg,var(--shimmer-base)_10%,var(--shimmer-highlight)_40%,var(--shimmer-base)_70%)] bg-clip-text text-transparent"
>
{headerText}
</Text>
</div>
}
/>
);
}
// Display content only (no timeline steps) - but show header for image generation
if (uiState === TimelineUIState.DISPLAY_CONTENT_ONLY) {
return (
<TimelineContainer className={className} agent={chatState.assistant} />
);
}
return (
<TimelineContainer
className={className}
agent={chatState.assistant}
headerContent={
<div
className={cn(
"flex w-full min-w-0 h-full items-center justify-between pl-2 pr-1 transition-colors duration-300",
showTintedBackground && "bg-background-tint-00 rounded-t-12",
showRoundedBottom && "rounded-b-12"
)}
>
{renderHeader()}
</div>
}
>
{/* Collapsed streaming view - single step compact mode */}
{showCollapsedCompact && lastStep && (
<CollapsedStreamingContent
step={lastStep}
chatState={chatState}
stopReason={stopReason}
renderTypeOverride={collapsedRenderTypeOverride}
/>
)}
{/* Collapsed streaming view - parallel tools compact mode */}
{showCollapsedParallel && parallelActiveStep && (
<CollapsedStreamingContent
step={parallelActiveStep}
chatState={chatState}
stopReason={stopReason}
renderTypeOverride={RenderType.HIGHLIGHT}
/>
)}
{/* Expanded timeline view */}
{isExpanded && (
<ExpandedTimelineContent
turnGroups={turnGroups}
chatState={chatState}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
isSingleStep={isSingleStep}
userStopped={userStopped}
showDoneStep={showDoneStep}
showStoppedStep={showStoppedStep}
hasDoneIndicator={hasDoneIndicator}
/>
)}
</TimelineContainer>
);
}, areAgentTimelinePropsEqual);
export default AgentTimeline;

View File

@@ -0,0 +1,83 @@
"use client";
import React, { useCallback } from "react";
import { StopReason } from "@/app/app/services/streamingModels";
import { FullChatState, RenderType } from "../interfaces";
import { TransformedStep } from "./transformers";
import { cn } from "@/lib/utils";
import {
TimelineRendererComponent,
TimelineRendererOutput,
} from "./TimelineRendererComponent";
// =============================================================================
// TimelineContentRow - Layout helper for content rows
// =============================================================================
interface TimelineContentRowProps {
className?: string;
children: React.ReactNode;
}
const TimelineContentRow: React.FC<TimelineContentRowProps> = ({
className,
children,
}) => (
<div className="flex w-full">
<div className="w-9" />
<div className={cn("w-full", className)}>{children}</div>
</div>
);
// =============================================================================
// CollapsedStreamingContent Component
// =============================================================================
export interface CollapsedStreamingContentProps {
step: TransformedStep;
chatState: FullChatState;
stopReason?: StopReason;
renderTypeOverride?: RenderType;
}
export const CollapsedStreamingContent = React.memo(
function CollapsedStreamingContent({
step,
chatState,
stopReason,
renderTypeOverride,
}: CollapsedStreamingContentProps) {
const noopComplete = useCallback(() => {}, []);
const renderContentOnly = useCallback(
(results: TimelineRendererOutput) => (
<>
{results.map((result, index) => (
<React.Fragment key={index}>{result.content}</React.Fragment>
))}
</>
),
[]
);
return (
<TimelineContentRow className="bg-background-tint-00 rounded-b-12 px-2 pb-2">
<TimelineRendererComponent
key={`${step.key}-compact`}
packets={step.packets}
chatState={chatState}
onComplete={noopComplete}
animate={true}
stopPacketSeen={false}
stopReason={stopReason}
defaultExpanded={false}
renderTypeOverride={renderTypeOverride}
isLastStep={true}
>
{renderContentOnly}
</TimelineRendererComponent>
</TimelineContentRow>
);
}
);
export default CollapsedStreamingContent;

View File

@@ -0,0 +1,221 @@
"use client";
import React, { FunctionComponent, useMemo, useCallback } from "react";
import { StopReason } from "@/app/app/services/streamingModels";
import { FullChatState } from "../interfaces";
import { TurnGroup, TransformedStep } from "./transformers";
import { SvgCheckCircle, SvgStopCircle } from "@opal/icons";
import { IconProps } from "@opal/types";
import {
TimelineRendererComponent,
TimelineRendererResult,
TimelineRendererOutput,
} from "./TimelineRendererComponent";
import { ParallelTimelineTabs } from "./ParallelTimelineTabs";
import { StepContainer } from "./StepContainer";
import {
isResearchAgentPackets,
isSearchToolPackets,
isReasoningPackets,
} from "@/app/app/message/messageComponents/timeline/packetHelpers";
// =============================================================================
// TimelineStep Component - Memoized to prevent re-renders
// =============================================================================
interface TimelineStepProps {
step: TransformedStep;
chatState: FullChatState;
stopPacketSeen: boolean;
stopReason?: StopReason;
isLastStep: boolean;
isFirstStep: boolean;
isSingleStep: boolean;
isStreaming?: boolean;
}
const noopCallback = () => {};
const TimelineStep = React.memo(function TimelineStep({
step,
chatState,
stopPacketSeen,
stopReason,
isLastStep,
isFirstStep,
isSingleStep,
isStreaming = false,
}: TimelineStepProps) {
const isResearchAgent = useMemo(
() => isResearchAgentPackets(step.packets),
[step.packets]
);
const isSearchTool = useMemo(
() => isSearchToolPackets(step.packets),
[step.packets]
);
const isReasoning = useMemo(
() => isReasoningPackets(step.packets),
[step.packets]
);
const renderStep = useCallback(
(results: TimelineRendererOutput) => {
if (isResearchAgent) {
return (
<>
{results.map((result, index) => (
<React.Fragment key={index}>{result.content}</React.Fragment>
))}
</>
);
}
return (
<>
{results.map((result, index) => (
<StepContainer
key={index}
stepIcon={result.icon as FunctionComponent<IconProps> | undefined}
header={result.status}
isExpanded={result.isExpanded}
onToggle={result.onToggle}
collapsible={true}
supportsCollapsible={result.supportsCollapsible}
isLastStep={index === results.length - 1 && isLastStep}
isFirstStep={index === 0 && isFirstStep}
hideHeader={results.length === 1 && isSingleStep}
collapsedIcon={
isSearchTool
? (result.icon as FunctionComponent<IconProps>)
: undefined
}
noPaddingRight={isReasoning}
>
{result.content}
</StepContainer>
))}
</>
);
},
[
isResearchAgent,
isSearchTool,
isReasoning,
isFirstStep,
isLastStep,
isSingleStep,
]
);
return (
<TimelineRendererComponent
packets={step.packets}
chatState={chatState}
onComplete={noopCallback}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
defaultExpanded={isStreaming || isSingleStep}
isLastStep={isLastStep}
>
{renderStep}
</TimelineRendererComponent>
);
});
// =============================================================================
// ExpandedTimelineContent Component
// =============================================================================
export interface ExpandedTimelineContentProps {
turnGroups: TurnGroup[];
chatState: FullChatState;
stopPacketSeen: boolean;
stopReason?: StopReason;
isSingleStep: boolean;
userStopped: boolean;
showDoneStep: boolean;
showStoppedStep: boolean;
hasDoneIndicator: boolean;
}
export const ExpandedTimelineContent = React.memo(
function ExpandedTimelineContent({
turnGroups,
chatState,
stopPacketSeen,
stopReason,
isSingleStep,
userStopped,
showDoneStep,
showStoppedStep,
hasDoneIndicator,
}: ExpandedTimelineContentProps) {
return (
<div className="w-full">
{turnGroups.map((turnGroup, turnIdx) =>
turnGroup.isParallel ? (
<ParallelTimelineTabs
key={turnGroup.turnIndex}
turnGroup={turnGroup}
chatState={chatState}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
isLastTurnGroup={turnIdx === turnGroups.length - 1}
/>
) : (
turnGroup.steps.map((step, stepIdx) => {
const stepIsLast =
turnIdx === turnGroups.length - 1 &&
stepIdx === turnGroup.steps.length - 1 &&
!hasDoneIndicator &&
!userStopped;
const stepIsFirst = turnIdx === 0 && stepIdx === 0;
return (
<TimelineStep
key={step.key}
step={step}
chatState={chatState}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
isLastStep={stepIsLast}
isFirstStep={stepIsFirst}
isSingleStep={isSingleStep}
isStreaming={!stopPacketSeen && !userStopped}
/>
);
})
)
)}
{/* Done indicator */}
{showDoneStep && (
<StepContainer
stepIcon={SvgCheckCircle}
header="Done"
isLastStep={true}
isFirstStep={false}
>
{null}
</StepContainer>
)}
{/* Stopped indicator */}
{showStoppedStep && (
<StepContainer
stepIcon={SvgStopCircle}
header="Stopped"
isLastStep={true}
isFirstStep={false}
>
{null}
</StepContainer>
)}
</div>
);
}
);
export default ExpandedTimelineContent;

View File

@@ -0,0 +1,210 @@
"use client";
import React, {
useState,
useMemo,
useCallback,
FunctionComponent,
} from "react";
import { cn } from "@/lib/utils";
import { StopReason } from "@/app/app/services/streamingModels";
import { FullChatState } from "../interfaces";
import { TurnGroup } from "./transformers";
import {
getToolName,
getToolIcon,
isToolComplete,
} from "../toolDisplayHelpers";
import {
TimelineRendererComponent,
TimelineRendererOutput,
} from "./TimelineRendererComponent";
import Tabs from "@/refresh-components/Tabs";
import { SvgBranch, SvgFold, SvgExpand } from "@opal/icons";
import { StepContainer } from "./StepContainer";
import { isResearchAgentPackets } from "@/app/app/message/messageComponents/timeline/packetHelpers";
import { IconProps } from "@/components/icons/icons";
import IconButton from "@/refresh-components/buttons/IconButton";
export interface ParallelTimelineTabsProps {
/** Turn group containing parallel steps */
turnGroup: TurnGroup;
/** Chat state for rendering content */
chatState: FullChatState;
/** Whether the stop packet has been seen */
stopPacketSeen: boolean;
/** Reason for stopping (if stopped) */
stopReason?: StopReason;
/** Whether this is the last turn group (affects connector line) */
isLastTurnGroup: boolean;
/** Additional class names */
className?: string;
}
export function ParallelTimelineTabs({
turnGroup,
chatState,
stopPacketSeen,
stopReason,
isLastTurnGroup,
className,
}: ParallelTimelineTabsProps) {
const [activeTab, setActiveTab] = useState(turnGroup.steps[0]?.key ?? "");
const [isExpanded, setIsExpanded] = useState(true);
const [isHover, setIsHover] = useState(false);
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
// Find the active step based on selected tab
const activeStep = useMemo(
() => turnGroup.steps.find((step) => step.key === activeTab),
[turnGroup.steps, activeTab]
);
// Memoized loading states for each step
const loadingStates = useMemo(
() =>
new Map(
turnGroup.steps.map((step) => [
step.key,
!stopPacketSeen &&
step.packets.length > 0 &&
!isToolComplete(step.packets),
])
),
[turnGroup.steps, stopPacketSeen]
);
// Check if any step is a research agent (only research agents get collapse button)
const hasResearchAgent = useMemo(
() => turnGroup.steps.some((step) => isResearchAgentPackets(step.packets)),
[turnGroup.steps]
);
//will be removed on cleanup
// Stable callbacks to avoid creating new functions on every render
const noopComplete = useCallback(() => {}, []);
const renderTabContent = useCallback(
(results: TimelineRendererOutput) => {
if (isResearchAgentPackets(activeStep?.packets ?? [])) {
return (
<>
{results.map((result, index) => (
<React.Fragment key={index}>{result.content}</React.Fragment>
))}
</>
);
}
return (
<>
{results.map((result, index) => (
<StepContainer
key={index}
stepIcon={result.icon as FunctionComponent<IconProps> | undefined}
header={result.status}
isExpanded={result.isExpanded}
onToggle={result.onToggle}
collapsible={true}
isLastStep={index === results.length - 1 && isLastTurnGroup}
isFirstStep={false}
isHover={result.isHover}
>
{result.content}
</StepContainer>
))}
</>
);
},
[activeStep?.packets, isLastTurnGroup]
);
return (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="flex flex-col w-full">
<div
className="flex w-full"
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
{/* Left column: Icon + connector line */}
<div className="flex flex-col items-center w-9 pt-2">
<div
className={cn(
"size-5 flex items-center justify-center text-text-02",
isHover &&
"text-text-inverted-05 bg-background-neutral-inverted-00 rounded-full"
)}
>
<SvgBranch className="w-3 h-3" />
</div>
{/* Connector line */}
<div
className={cn(
"w-px flex-1 bg-border-01",
isHover && "bg-border-04"
)}
/>
</div>
{/* Right column: Tabs + collapse button */}
<div
className={cn(
"w-full pl-1 bg-background-tint-00",
isHover && "bg-background-tint-02"
)}
>
<Tabs.List
variant="pill"
enableScrollArrows
className={cn(
isHover && "bg-background-tint-02",
"transition-colors duration-200"
)}
rightContent={
<IconButton
tertiary
onClick={handleToggle}
icon={isExpanded ? SvgFold : SvgExpand}
/>
}
>
{turnGroup.steps.map((step) => (
<Tabs.Trigger
key={step.key}
value={step.key}
variant="pill"
isLoading={loadingStates.get(step.key)}
>
<span className="flex items-center gap-1.5">
{getToolIcon(step.packets)}
{getToolName(step.packets)}
</span>
</Tabs.Trigger>
))}
</Tabs.List>
</div>
</div>
<div className="w-full">
<TimelineRendererComponent
key={`${activeTab}-${isExpanded}`}
packets={
!isExpanded && stopPacketSeen ? [] : activeStep?.packets ?? []
}
chatState={chatState}
onComplete={noopComplete}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
defaultExpanded={isExpanded}
isLastStep={isLastTurnGroup}
isHover={isHover}
>
{renderTabContent}
</TimelineRendererComponent>
</div>
</div>
</Tabs>
);
}
export default ParallelTimelineTabs;

View File

@@ -21,8 +21,8 @@ export interface StepContainerProps {
onToggle?: () => void;
/** Whether collapse control is shown */
collapsible?: boolean;
/** Collapse button shown only when renderer supports compact mode */
supportsCompact?: boolean;
/** Collapse button shown only when renderer supports collapsible mode */
supportsCollapsible?: boolean;
/** Additional class names */
className?: string;
/** Last step (no bottom connector) */
@@ -31,6 +31,12 @@ export interface StepContainerProps {
isFirstStep?: boolean;
/** Hide header (single-step timelines) */
hideHeader?: boolean;
/** Hover state from parent */
isHover?: boolean;
/** Custom icon to show when collapsed (defaults to SvgExpand) */
collapsedIcon?: FunctionComponent<IconProps>;
/** Remove right padding (for reasoning content) */
noPaddingRight?: boolean;
}
/** Visual wrapper for timeline steps - icon, connector line, header, and content */
@@ -42,50 +48,70 @@ export function StepContainer({
isExpanded = true,
onToggle,
collapsible = true,
supportsCompact = false,
supportsCollapsible = false,
isLastStep = false,
isFirstStep = false,
className,
hideHeader = false,
isHover = false,
collapsedIcon: CollapsedIconComponent,
noPaddingRight = false,
}: StepContainerProps) {
const showCollapseControls = collapsible && supportsCompact && onToggle;
const showCollapseControls = collapsible && supportsCollapsible && onToggle;
return (
<div className={cn("flex w-full", className)}>
<div
className={cn("flex flex-col items-center w-9", isFirstStep && "pt-2")}
className={cn(
"flex flex-col items-center w-9",
isFirstStep && "pt-0.5",
!isFirstStep && "pt-1.5"
)}
>
{/* Icon */}
{!hideHeader && StepIconComponent && (
<div className="py-1">
<StepIconComponent className="size-4 stroke-text-02" />
<div className="flex py-1 h-8 items-center justify-center">
<StepIconComponent
className={cn(
"size-3 stroke-text-02",
isHover && "stroke-text-04"
)}
/>
</div>
)}
{/* Connector line */}
{!isLastStep && <div className="w-px flex-1 bg-border-01" />}
{!isLastStep && (
<div
className={cn(
"w-px h-full bg-border-01",
isHover && "bg-border-04"
)}
/>
)}
</div>
<div
className={cn(
"w-full bg-background-tint-00",
isLastStep && "rounded-b-12"
"w-full bg-background-tint-00 transition-colors duration-200",
isLastStep && "rounded-b-12",
isHover && "bg-background-tint-02"
)}
>
{!hideHeader && (
<div className="flex items-center justify-between px-2">
{header && (
<Text as="p" mainUiMuted text03>
{header}
</Text>
)}
{!hideHeader && header && (
<div className="flex items-center justify-between pl-2 pr-1 h-8">
<Text as="p" mainUiMuted text04>
{header}
</Text>
{showCollapseControls &&
(buttonTitle ? (
<Button
tertiary
onClick={onToggle}
rightIcon={isExpanded ? SvgFold : SvgExpand}
rightIcon={
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
}
>
{buttonTitle}
</Button>
@@ -93,13 +119,17 @@ export function StepContainer({
<IconButton
tertiary
onClick={onToggle}
icon={isExpanded ? SvgFold : SvgExpand}
icon={
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
}
/>
))}
</div>
)}
<div className="px-2 pb-2">{children}</div>
<div className={cn("px-2 pb-2", !noPaddingRight && "pr-8")}>
{children}
</div>
</div>
</div>
);

View File

@@ -1,8 +1,13 @@
"use client";
import React, { useState, JSX } from "react";
import { Packet, StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState, RenderType, RendererResult } from "../interfaces";
import React, { useState, useCallback, JSX } from "react";
import { Packet, StopReason } from "@/app/app/services/streamingModels";
import {
FullChatState,
RenderType,
RendererResult,
RendererOutput,
} from "../interfaces";
import { findRenderer } from "../renderMessageComponent";
/** Extended result that includes collapse state */
@@ -15,8 +20,13 @@ export interface TimelineRendererResult extends RendererResult {
renderType: RenderType;
/** Whether this is the last step (passed through from props) */
isLastStep: boolean;
/** Hover state from parent */
isHover: boolean;
}
// All renderers return an array of results
export type TimelineRendererOutput = TimelineRendererResult[];
export interface TimelineRendererComponentProps {
/** Packets to render */
packets: Packet[];
@@ -34,8 +44,12 @@ export interface TimelineRendererComponentProps {
defaultExpanded?: boolean;
/** Whether this is the last step in the timeline (for connector line decisions) */
isLastStep?: boolean;
/** Children render function - receives extended result with collapse state */
children: (result: TimelineRendererResult) => JSX.Element;
/** Hover state from parent component */
isHover?: boolean;
/** Override render type (if not set, derives from defaultExpanded) */
renderTypeOverride?: RenderType;
/** Children render function - receives extended result with collapse state (single or array) */
children: (result: TimelineRendererOutput) => JSX.Element;
}
// Custom comparison function to prevent unnecessary re-renders
@@ -50,7 +64,9 @@ function arePropsEqual(
prev.stopReason === next.stopReason &&
prev.animate === next.animate &&
prev.isLastStep === next.isLastStep &&
prev.defaultExpanded === next.defaultExpanded
prev.isHover === next.isHover &&
prev.defaultExpanded === next.defaultExpanded &&
prev.renderTypeOverride === next.renderTypeOverride
// Skipping chatState (memoized upstream)
);
}
@@ -65,26 +81,42 @@ export const TimelineRendererComponent = React.memo(
stopReason,
defaultExpanded = true,
isLastStep,
isHover = false,
renderTypeOverride,
children,
}: TimelineRendererComponentProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const handleToggle = () => setIsExpanded((prev) => !prev);
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const RendererFn = findRenderer({ packets });
const renderType = isExpanded ? RenderType.FULL : RenderType.COMPACT;
const renderType =
renderTypeOverride ?? (isExpanded ? RenderType.FULL : RenderType.COMPACT);
if (!RendererFn) {
return children({
icon: null,
status: null,
content: <></>,
supportsCompact: false,
isExpanded,
onToggle: handleToggle,
renderType,
isLastStep: isLastStep ?? true,
});
return children([
{
icon: null,
status: null,
content: <></>,
supportsCollapsible: false,
isExpanded,
onToggle: handleToggle,
renderType,
isLastStep: isLastStep ?? true,
isHover,
},
]);
}
// Helper to add timeline context to a result
const enhanceResult = (result: RendererResult): TimelineRendererResult => ({
...result,
isExpanded,
onToggle: handleToggle,
renderType,
isLastStep: isLastStep ?? true,
isHover,
});
return (
<RendererFn
packets={packets as any}
@@ -95,19 +127,10 @@ export const TimelineRendererComponent = React.memo(
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
isLastStep={isLastStep}
isHover={isHover}
>
{({ icon, status, content, expandedText, supportsCompact }) =>
children({
icon,
status,
content,
expandedText,
supportsCompact,
isExpanded,
onToggle: handleToggle,
renderType,
isLastStep: isLastStep ?? true,
})
{(rendererOutput: RendererOutput) =>
children(rendererOutput.map((result) => enhanceResult(result)))
}
</RendererFn>
);

View File

@@ -0,0 +1,61 @@
import React from "react";
import { SvgExpand } from "@opal/icons";
import Button from "@/refresh-components/buttons/Button";
import Text from "@/refresh-components/texts/Text";
import { formatDurationSeconds } from "@/lib/time";
export interface CollapsedHeaderProps {
totalSteps: number;
collapsible: boolean;
onToggle: () => void;
processingDurationSeconds?: number;
generatedImageCount?: number;
}
/** Header when completed + collapsed - duration text + step count */
export const CollapsedHeader = React.memo(function CollapsedHeader({
totalSteps,
collapsible,
onToggle,
processingDurationSeconds = 0,
generatedImageCount = 0,
}: CollapsedHeaderProps) {
const durationText = processingDurationSeconds
? `Thought for ${formatDurationSeconds(processingDurationSeconds)}`
: "Thought for some time";
const imageText =
generatedImageCount > 0
? `Generated ${generatedImageCount} ${
generatedImageCount === 1 ? "image" : "images"
}`
: null;
return (
<>
<div className="flex flex-col">
{!imageText && (
<Text as="p" mainUiAction text03>
{durationText}
</Text>
)}
{imageText && (
<Text as="p" mainUiAction text03>
{imageText}
</Text>
)}
</div>
{collapsible && totalSteps > 0 && (
<Button
tertiary
onClick={onToggle}
rightIcon={SvgExpand}
aria-label="Expand timeline"
aria-expanded={false}
>
{totalSteps} {totalSteps === 1 ? "step" : "steps"}
</Button>
)}
</>
);
});

View File

@@ -2,21 +2,28 @@ import React from "react";
import { SvgFold } from "@opal/icons";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import { formatDurationSeconds } from "@/lib/time";
export interface ExpandedHeaderProps {
collapsible: boolean;
onToggle: () => void;
processingDurationSeconds?: number;
}
/** Header when completed + expanded */
export const ExpandedHeader = React.memo(function ExpandedHeader({
collapsible,
onToggle,
processingDurationSeconds,
}: ExpandedHeaderProps) {
const durationText = processingDurationSeconds
? `Thought for ${formatDurationSeconds(processingDurationSeconds)}`
: "Thought for some time";
return (
<>
<Text as="p" mainUiAction text03>
Thought for some time
{durationText}
</Text>
{collapsible && (
<IconButton

View File

@@ -0,0 +1,79 @@
import React, { useMemo } from "react";
import { SvgFold, SvgExpand } from "@opal/icons";
import IconButton from "@/refresh-components/buttons/IconButton";
import Tabs from "@/refresh-components/Tabs";
import { TurnGroup } from "../transformers";
import {
getToolIcon,
getToolName,
isToolComplete,
} from "../../toolDisplayHelpers";
export interface ParallelStreamingHeaderProps {
steps: TurnGroup["steps"];
activeTab: string;
onTabChange: (tab: string) => void;
collapsible: boolean;
isExpanded: boolean;
onToggle: () => void;
}
/** Header during streaming with parallel tools - tabs only */
export const ParallelStreamingHeader = React.memo(
function ParallelStreamingHeader({
steps,
activeTab,
onTabChange,
collapsible,
isExpanded,
onToggle,
}: ParallelStreamingHeaderProps) {
// Memoized loading states for each step
const loadingStates = useMemo(
() =>
new Map(
steps.map((step) => [
step.key,
step.packets.length > 0 && !isToolComplete(step.packets),
])
),
[steps]
);
return (
<Tabs value={activeTab} onValueChange={onTabChange}>
<Tabs.List
variant="pill"
enableScrollArrows
rightContent={
collapsible ? (
<IconButton
tertiary
onClick={onToggle}
icon={isExpanded ? SvgFold : SvgExpand}
aria-label={
isExpanded ? "Collapse timeline" : "Expand timeline"
}
aria-expanded={isExpanded}
/>
) : undefined
}
>
{steps.map((step) => (
<Tabs.Trigger
key={step.key}
value={step.key}
variant="pill"
isLoading={loadingStates.get(step.key)}
>
<span className="flex items-center gap-1.5">
{getToolIcon(step.packets)}
{getToolName(step.packets)}
</span>
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs>
);
}
);

View File

@@ -20,9 +20,9 @@ export const StoppedHeader = React.memo(function StoppedHeader({
return (
<>
<Text as="p" mainUiAction text03>
Stopped Thinking
Interrupted Thinking
</Text>
{collapsible && (
{collapsible && totalSteps > 0 && (
<Button
tertiary
onClick={onToggle}

View File

@@ -3,6 +3,8 @@ import { SvgFold, SvgExpand } from "@opal/icons";
import Button from "@/refresh-components/buttons/Button";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import { useStreamingDuration } from "../hooks/useStreamingDuration";
import { formatDurationSeconds } from "@/lib/time";
export interface StreamingHeaderProps {
headerText: string;
@@ -10,6 +12,9 @@ export interface StreamingHeaderProps {
buttonTitle?: string;
isExpanded: boolean;
onToggle: () => void;
streamingStartTime?: number;
/** Tool processing duration from backend (freezes timer when available) */
toolProcessingDuration?: number;
}
/** Header during streaming - shimmer text with current activity */
@@ -19,7 +24,18 @@ export const StreamingHeader = React.memo(function StreamingHeader({
buttonTitle,
isExpanded,
onToggle,
streamingStartTime,
toolProcessingDuration,
}: StreamingHeaderProps) {
// Use backend duration when available, otherwise continue live timer
const elapsedSeconds = useStreamingDuration(
toolProcessingDuration === undefined, // Stop updating when we have backend duration
streamingStartTime,
toolProcessingDuration
);
const showElapsedTime =
isExpanded && streamingStartTime && elapsedSeconds > 0;
return (
<>
<Text
@@ -40,6 +56,16 @@ export const StreamingHeader = React.memo(function StreamingHeader({
>
{buttonTitle}
</Button>
) : showElapsedTime ? (
<Button
tertiary
onClick={onToggle}
rightIcon={SvgFold}
aria-label="Collapse timeline"
aria-expanded={true}
>
{formatDurationSeconds(elapsedSeconds)}
</Button>
) : (
<IconButton
tertiary

View File

@@ -8,17 +8,17 @@ import {
FetchToolDocuments,
TopLevelBranching,
Stop,
SearchToolStart,
CustomToolStart,
} from "@/app/chat/services/streamingModels";
import { CitationMap } from "@/app/chat/interfaces";
ImageGenerationToolDelta,
MessageStart,
} from "@/app/app/services/streamingModels";
import { CitationMap } from "@/app/app/interfaces";
import { OnyxDocument } from "@/lib/search/interfaces";
import {
isActualToolCallPacket,
isToolPacket,
isDisplayPacket,
} from "@/app/chat/services/packetUtils";
import { parseToolKey } from "@/app/chat/message/messageComponents/toolDisplayHelpers";
} from "@/app/app/services/packetUtils";
import { parseToolKey } from "@/app/app/message/messageComponents/toolDisplayHelpers";
// Re-export parseToolKey for consumers that import from this module
export { parseToolKey };
@@ -49,18 +49,21 @@ export interface ProcessorState {
toolGroupKeys: Set<string>;
displayGroupKeys: Set<string>;
// Unique tool names tracking (populated during packet processing)
uniqueToolNames: Set<string>;
// Image generation status
isGeneratingImage: boolean;
generatedImageCount: number;
// Streaming status
finalAnswerComing: boolean;
stopPacketSeen: boolean;
stopReason: StopReason | undefined;
// Tool processing duration from backend (captured when MESSAGE_START arrives)
toolProcessingDuration: number | undefined;
// Result arrays (built at end of processPackets)
toolGroups: GroupedPacket[];
potentialDisplayGroups: GroupedPacket[];
uniqueToolNamesArray: string[];
}
export interface GroupedPacket {
@@ -87,13 +90,14 @@ export function createInitialState(nodeId: number): ProcessorState {
expectedBranches: new Map(),
toolGroupKeys: new Set(),
displayGroupKeys: new Set(),
uniqueToolNames: new Set(),
isGeneratingImage: false,
generatedImageCount: 0,
finalAnswerComing: false,
stopPacketSeen: false,
stopReason: undefined,
toolProcessingDuration: undefined,
toolGroups: [],
potentialDisplayGroups: [],
uniqueToolNamesArray: [],
};
}
@@ -147,37 +151,6 @@ function hasContentPackets(packets: Packet[]): boolean {
);
}
/**
* Extract tool name from a packet for unique tool tracking.
* Returns null for non-tool packets.
*/
function getToolNameFromPacket(packet: Packet): string | null {
switch (packet.obj.type) {
case PacketType.SEARCH_TOOL_START: {
const searchPacket = packet.obj as SearchToolStart;
return searchPacket.is_internet_search ? "Web Search" : "Internal Search";
}
case PacketType.PYTHON_TOOL_START:
return "Code Interpreter";
case PacketType.FETCH_TOOL_START:
return "Open URLs";
case PacketType.CUSTOM_TOOL_START: {
const customPacket = packet.obj as CustomToolStart;
return customPacket.tool_name || "Custom Tool";
}
case PacketType.IMAGE_GENERATION_TOOL_START:
return "Generate Image";
case PacketType.DEEP_RESEARCH_PLAN_START:
return "Generate plan";
case PacketType.RESEARCH_AGENT_START:
return "Research agent";
case PacketType.REASONING_START:
return "Thinking";
default:
return null;
}
}
/**
* Packet types that indicate final answer content is coming
*/
@@ -272,6 +245,15 @@ function handleStreamingStatusPacket(
if (FINAL_ANSWER_PACKET_TYPES_SET.has(packet.obj.type as PacketType)) {
state.finalAnswerComing = true;
}
// Capture tool processing duration from MESSAGE_START packet
if (packet.obj.type === PacketType.MESSAGE_START) {
const messageStart = packet.obj as MessageStart;
if (messageStart.tool_processing_duration_seconds !== undefined) {
state.toolProcessingDuration =
messageStart.tool_processing_duration_seconds;
}
}
}
function handleStopPacket(state: ProcessorState, packet: Packet): void {
@@ -363,15 +345,21 @@ function processPacket(state: ProcessorState, packet: Packet): void {
if (isFirstPacket) {
if (isToolPacket(packet, false)) {
state.toolGroupKeys.add(groupKey);
// Track unique tool name
const toolName = getToolNameFromPacket(packet);
if (toolName) {
state.uniqueToolNames.add(toolName);
}
}
if (isDisplayPacket(packet)) {
state.displayGroupKeys.add(groupKey);
}
// Track image generation for header display
if (packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START) {
state.isGeneratingImage = true;
}
}
// Count generated images from DELTA packets
if (packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_DELTA) {
const delta = packet.obj as ImageGenerationToolDelta;
state.generatedImageCount += delta.images?.length ?? 0;
}
// Handle specific packet types
@@ -391,6 +379,9 @@ export function processPackets(
state = createInitialState(state.nodeId);
}
// Track if we processed any new packets
const prevProcessedIndex = state.lastProcessedIndex;
// Process only new packets
for (let i = state.lastProcessedIndex; i < rawPackets.length; i++) {
const packet = rawPackets[i];
@@ -401,13 +392,16 @@ export function processPackets(
state.lastProcessedIndex = rawPackets.length;
// Build result arrays after processing
state.toolGroups = buildGroupsFromKeys(state, state.toolGroupKeys);
state.potentialDisplayGroups = buildGroupsFromKeys(
state,
state.displayGroupKeys
);
state.uniqueToolNamesArray = Array.from(state.uniqueToolNames);
// Only rebuild result arrays if we processed new packets
// This prevents creating new references when nothing changed
if (prevProcessedIndex !== rawPackets.length) {
// Build result arrays after processing new packets
state.toolGroups = buildGroupsFromKeys(state, state.toolGroupKeys);
state.potentialDisplayGroups = buildGroupsFromKeys(
state,
state.displayGroupKeys
);
}
return state;
}
@@ -415,6 +409,43 @@ export function processPackets(
/**
* Build GroupedPacket array from a set of group keys.
* Filters to only include groups with meaningful content and sorts by turn/tab index.
*
* @example
* // Input: state.groupedPacketsMap + keys Set
* // ┌─────────────────────────────────────────────────────┐
* // │ groupedPacketsMap = { │
* // │ "0-0" → [packet1, packet2] │
* // │ "0-1" → [packet3] │
* // │ "1-0" → [packet4, packet5] │
* // │ "2-0" → [empty_packet] ← no content packets │
* // │ } │
* // │ keys = Set{"0-0", "0-1", "1-0", "2-0"} │
* // └─────────────────────────────────────────────────────┘
* //
* // Step 1: Map keys → GroupedPacket (parse key, lookup packets)
* // ┌─────────────────────────────────────────────────────┐
* // │ "0-0" → { turn_index:0, tab_index:0, packets:[...] }│
* // │ "0-1" → { turn_index:0, tab_index:1, packets:[...] }│
* // │ "1-0" → { turn_index:1, tab_index:0, packets:[...] }│
* // │ "2-0" → { turn_index:2, tab_index:0, packets:[...] }│
* // └─────────────────────────────────────────────────────┘
* //
* // Step 2: Filter (hasContentPackets check)
* // ┌─────────────────────────────────────────────────────┐
* // │ ✓ "0-0" has MESSAGE_START → keep │
* // │ ✓ "0-1" has SEARCH_TOOL_START → keep │
* // │ ✓ "1-0" has PYTHON_TOOL_START → keep │
* // │ ✗ "2-0" no content packets → filtered out │
* // └─────────────────────────────────────────────────────┘
* //
* // Step 3: Sort by turn_index, then tab_index
* // ┌─────────────────────────────────────────────────────┐
* // │ Output: GroupedPacket[] │
* // ├─────────────────────────────────────────────────────┤
* // │ [0] turn_index=0, tab_index=0, packets=[...] │
* // │ [1] turn_index=0, tab_index=1, packets=[...] │
* // │ [2] turn_index=1, tab_index=0, packets=[...] │
* // └─────────────────────────────────────────────────────┘
*/
function buildGroupsFromKeys(
state: ProcessorState,

View File

@@ -0,0 +1,357 @@
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { PacketType } from "@/app/app/services/streamingModels";
import { GroupedPacket } from "./packetProcessor";
import { TurnGroup, TransformedStep } from "../transformers";
// Delay between different packet types (ms)
const PACING_DELAY_MS = 200;
/**
* Tool START packet types used for categorizing steps
* These determine the "type" of a step for pacing purposes
*/
const TOOL_START_PACKET_TYPES = new Set<PacketType>([
PacketType.SEARCH_TOOL_START,
PacketType.FETCH_TOOL_START,
PacketType.PYTHON_TOOL_START,
PacketType.CUSTOM_TOOL_START,
PacketType.REASONING_START,
PacketType.IMAGE_GENERATION_TOOL_START,
PacketType.DEEP_RESEARCH_PLAN_START,
PacketType.RESEARCH_AGENT_START,
]);
/**
* Get the primary packet type from a step's packets (first START packet)
* Used to determine if a type transition occurred
*/
function getStepPacketType(step: TransformedStep): PacketType | null {
for (const packet of step.packets) {
if (TOOL_START_PACKET_TYPES.has(packet.obj.type as PacketType)) {
return packet.obj.type as PacketType;
}
}
return null;
}
/**
* Internal pacing state stored in ref (not triggering re-renders)
*/
interface PacingState {
// Tracking revealed content
revealedStepKeys: Set<string>;
lastRevealedPacketType: PacketType | null;
// Queued content
pendingSteps: TransformedStep[];
// Timer
pacingTimer: ReturnType<typeof setTimeout> | null;
// Flags
toolPacingComplete: boolean;
stopPacketSeen: boolean;
// Track nodeId for reset detection
nodeId: string | null;
}
function createInitialPacingState(): PacingState {
return {
revealedStepKeys: new Set(),
lastRevealedPacketType: null,
pendingSteps: [],
pacingTimer: null,
toolPacingComplete: false,
stopPacketSeen: false,
nodeId: null,
};
}
/**
* Hook that adds pacing delays when packet types change during streaming.
* Creates visual breathing room between different agent activities.
*
* Architecture:
* - Pacing state in ref: no re-renders for internal tracking
* - useState only for revealTrigger: forces re-render when content should update
* - Timer-based delays: 200ms between different packet types
*
* @param toolTurnGroups - Turn groups from packet processor
* @param displayGroups - Display content groups (MESSAGE_START/DELTA)
* @param stopPacketSeen - Whether STOP packet has been received
* @param nodeId - Message node ID for reset detection
* @param finalAnswerComing - Whether message content is streaming
*/
export function usePacedTurnGroups(
toolTurnGroups: TurnGroup[],
displayGroups: GroupedPacket[],
stopPacketSeen: boolean,
nodeId: number,
finalAnswerComing: boolean
): {
pacedTurnGroups: TurnGroup[];
pacedDisplayGroups: GroupedPacket[];
pacedFinalAnswerComing: boolean;
} {
// Ref-based pacing state (no re-renders)
const stateRef = useRef<PacingState>(createInitialPacingState());
// Track previous finalAnswerComing to detect tool-after-message transitions
const prevFinalAnswerComingRef = useRef(finalAnswerComing);
// Trigger re-render when content should update
// Used in useMemo dependencies since state.revealedStepKeys is stored in a ref
const [revealTrigger, setRevealTrigger] = useState(0);
// Stable nodeId string for comparison
const nodeIdStr = String(nodeId);
// Reset on nodeId change
if (stateRef.current.nodeId !== nodeIdStr) {
if (stateRef.current.pacingTimer) {
clearTimeout(stateRef.current.pacingTimer);
}
stateRef.current = createInitialPacingState();
stateRef.current.nodeId = nodeIdStr;
}
const state = stateRef.current;
// Bypass pacing for completed messages (old messages loaded from history)
// If stopPacketSeen is true on first render, return everything immediately
const shouldBypassPacing =
stopPacketSeen &&
state.revealedStepKeys.size === 0 &&
toolTurnGroups.length > 0;
// Handle revealing the next pending step
// Uses a while loop to reveal all consecutive steps of the same type in a single pass
const revealNextPendingStep = useCallback(() => {
const state = stateRef.current;
// Reveal all consecutive steps of the same type in a single pass
while (state.pendingSteps.length > 0) {
const stepToReveal = state.pendingSteps.shift()!;
state.revealedStepKeys.add(stepToReveal.key);
state.lastRevealedPacketType = getStepPacketType(stepToReveal);
// Check if next step has different type - if so, schedule delay and exit
if (state.pendingSteps.length > 0) {
const nextType = getStepPacketType(state.pendingSteps[0]!);
if (nextType !== state.lastRevealedPacketType) {
state.pacingTimer = setTimeout(
revealNextPendingStep,
PACING_DELAY_MS
);
setRevealTrigger((t) => t + 1);
return;
}
// Same type - continue loop to reveal next immediately
}
}
// No more pending steps - pacing complete
state.toolPacingComplete = true;
state.pacingTimer = null;
setRevealTrigger((t) => t + 1);
}, []);
// Process incoming turn groups
useEffect(() => {
// Skip processing when bypassing pacing
if (shouldBypassPacing) return;
const state = stateRef.current;
// Detect tool-after-message transition: message was showing, now tools are starting
// Reset toolPacingComplete to hide display until new tools finish pacing
if (prevFinalAnswerComingRef.current && !finalAnswerComing) {
state.toolPacingComplete = false;
}
prevFinalAnswerComingRef.current = finalAnswerComing;
// Handle STOP packet - flush everything immediately
if (stopPacketSeen && !state.stopPacketSeen) {
state.stopPacketSeen = true;
// Clear any pending timer
if (state.pacingTimer) {
clearTimeout(state.pacingTimer);
state.pacingTimer = null;
}
// Reveal all pending steps immediately
for (const step of state.pendingSteps) {
state.revealedStepKeys.add(step.key);
}
state.pendingSteps = [];
state.toolPacingComplete = true;
setRevealTrigger((t) => t + 1);
return;
}
// Collect all steps from turn groups
const allSteps: TransformedStep[] = [];
for (const turnGroup of toolTurnGroups) {
for (const step of turnGroup.steps) {
allSteps.push(step);
}
}
// Find new steps (not yet revealed or pending)
const newSteps: TransformedStep[] = [];
const pendingKeys = new Set(state.pendingSteps.map((s) => s.key));
for (const step of allSteps) {
if (!state.revealedStepKeys.has(step.key) && !pendingKeys.has(step.key)) {
newSteps.push(step);
}
}
if (newSteps.length === 0) {
// If there are no tool steps at all, mark pacing complete immediately
// This allows tool-less responses to render their displayGroups
if (allSteps.length === 0 && !state.toolPacingComplete) {
state.toolPacingComplete = true;
setRevealTrigger((t) => t + 1);
return;
}
// Check if all steps are revealed (no pending, no new)
if (
state.pendingSteps.length === 0 &&
!state.pacingTimer &&
allSteps.length > 0
) {
const allRevealed = allSteps.every((s) =>
state.revealedStepKeys.has(s.key)
);
if (allRevealed && !state.toolPacingComplete) {
state.toolPacingComplete = true;
setRevealTrigger((t) => t + 1);
}
}
return;
}
// Process new steps
for (const step of newSteps) {
const stepType = getStepPacketType(step);
// First step ever - reveal immediately
if (
state.revealedStepKeys.size === 0 &&
state.pendingSteps.length === 0
) {
state.revealedStepKeys.add(step.key);
state.lastRevealedPacketType = stepType;
setRevealTrigger((t) => t + 1);
continue;
}
// Same type as last revealed (or pending) - handle based on current state
const effectiveLastType =
state.pendingSteps.length > 0
? getStepPacketType(
state.pendingSteps[state.pendingSteps.length - 1]!
)
: state.lastRevealedPacketType;
if (stepType === effectiveLastType) {
// Same type
if (state.pendingSteps.length === 0 && !state.pacingTimer) {
// Nothing pending, no timer - reveal immediately
state.revealedStepKeys.add(step.key);
state.lastRevealedPacketType = stepType;
setRevealTrigger((t) => t + 1);
} else {
// Add to pending queue (will be revealed when timer fires or queue processes)
state.pendingSteps.push(step);
}
} else {
// Different type - queue for paced reveal
state.pendingSteps.push(step);
// Start timer if not already running
if (!state.pacingTimer && state.pendingSteps.length === 1) {
state.pacingTimer = setTimeout(
revealNextPendingStep,
PACING_DELAY_MS
);
}
}
}
// Mark pacing incomplete while we have pending steps or timer
if (state.pendingSteps.length > 0 || state.pacingTimer) {
state.toolPacingComplete = false;
}
}, [
toolTurnGroups,
stopPacketSeen,
finalAnswerComing,
revealNextPendingStep,
shouldBypassPacing,
]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (stateRef.current.pacingTimer) {
clearTimeout(stateRef.current.pacingTimer);
}
};
}, []);
// Build paced turn groups from revealed step keys
// Memoized to prevent unnecessary re-renders in downstream components
// revealTrigger is included because state.revealedStepKeys is stored in a ref
const pacedTurnGroups = useMemo(() => {
// Bypass: return all turn groups immediately
if (shouldBypassPacing) return toolTurnGroups;
const result: TurnGroup[] = [];
for (const turnGroup of toolTurnGroups) {
const revealedSteps = turnGroup.steps.filter((step) =>
state.revealedStepKeys.has(step.key)
);
if (revealedSteps.length > 0) {
result.push({
turnIndex: turnGroup.turnIndex,
steps: revealedSteps,
isParallel: revealedSteps.length > 1,
});
}
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolTurnGroups, revealTrigger, shouldBypassPacing]);
// Only return display groups when tool pacing is complete (or bypassing)
const pacedDisplayGroups = useMemo(
() => (shouldBypassPacing || state.toolPacingComplete ? displayGroups : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[state.toolPacingComplete, displayGroups, revealTrigger, shouldBypassPacing]
);
// Paced signals for header state consistency
// Only signal finalAnswerComing when tool pacing is complete (or bypassing)
const pacedFinalAnswerComing = useMemo(
() => (shouldBypassPacing || state.toolPacingComplete) && finalAnswerComing,
// eslint-disable-next-line react-hooks/exhaustive-deps
[
state.toolPacingComplete,
finalAnswerComing,
revealTrigger,
shouldBypassPacing,
]
);
return {
pacedTurnGroups,
pacedDisplayGroups,
pacedFinalAnswerComing,
};
}

View File

@@ -3,20 +3,20 @@ import {
Packet,
StreamingCitation,
StopReason,
} from "@/app/chat/services/streamingModels";
import { CitationMap } from "@/app/chat/interfaces";
} from "@/app/app/services/streamingModels";
import { CitationMap } from "@/app/app/interfaces";
import { OnyxDocument } from "@/lib/search/interfaces";
import {
ProcessorState,
GroupedPacket,
createInitialState,
processPackets,
} from "@/app/chat/message/messageComponents/timeline/hooks/packetProcessor";
} from "@/app/app/message/messageComponents/timeline/hooks/packetProcessor";
import {
transformPacketGroups,
groupStepsByTurn,
TurnGroup,
} from "@/app/chat/message/messageComponents/timeline/transformers";
} from "@/app/app/message/messageComponents/timeline/transformers";
export interface UsePacketProcessorResult {
// Data
@@ -32,7 +32,12 @@ export interface UsePacketProcessorResult {
stopReason: StopReason | undefined;
hasSteps: boolean;
expectedBranchesPerTurn: Map<number, number>;
uniqueToolNames: string[];
isGeneratingImage: boolean;
generatedImageCount: number;
// Whether final answer is coming (MESSAGE_START seen)
finalAnswerComing: boolean;
// Tool processing duration from backend (via MESSAGE_START packet)
toolProcessingDuration: number | undefined;
// Completion: stopPacketSeen && renderComplete
isComplete: boolean;
@@ -141,7 +146,10 @@ export function usePacketProcessor(
stopReason: state.stopReason,
hasSteps: toolTurnGroups.length > 0,
expectedBranchesPerTurn: state.expectedBranches,
uniqueToolNames: state.uniqueToolNamesArray,
isGeneratingImage: state.isGeneratingImage,
generatedImageCount: state.generatedImageCount,
finalAnswerComing: state.finalAnswerComing,
toolProcessingDuration: state.toolProcessingDuration,
// Completion: stopPacketSeen && renderComplete
isComplete: state.stopPacketSeen && renderComplete,

View File

@@ -0,0 +1,64 @@
import { useState, useEffect, useRef } from "react";
/**
* Hook to track elapsed streaming duration with efficient updates.
*
* Uses requestAnimationFrame for accurate timing but only triggers re-renders
* when the elapsed seconds value actually changes (once per second).
*
* @param isStreaming - Whether streaming is currently active
* @param startTime - Timestamp when streaming started (from Date.now())
* @param backendDuration - Duration from backend when available (freezes timer)
* @returns Elapsed seconds since streaming started
*/
export function useStreamingDuration(
isStreaming: boolean,
startTime: number | undefined,
backendDuration?: number
): number {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const rafRef = useRef<number | null>(null);
const lastElapsedRef = useRef<number>(0);
// Determine if we should run the live timer
// Stop the timer when backend duration is available
const shouldRunTimer = isStreaming && backendDuration === undefined;
useEffect(() => {
if (!shouldRunTimer || !startTime) {
// Don't reset when stopping - preserve last calculated value
// Only reset when explicitly given no start time
if (!startTime) {
setElapsedSeconds(0);
lastElapsedRef.current = 0;
}
return;
}
const updateElapsed = () => {
const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000);
// Only update state when seconds change to avoid unnecessary re-renders
if (elapsed !== lastElapsedRef.current) {
lastElapsedRef.current = elapsed;
setElapsedSeconds(elapsed);
}
rafRef.current = requestAnimationFrame(updateElapsed);
};
// Start the animation loop
rafRef.current = requestAnimationFrame(updateElapsed);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [shouldRunTimer, startTime]);
// Return backend duration if provided, otherwise return live elapsed time
return backendDuration !== undefined ? backendDuration : elapsedSeconds;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { TurnGroup } from "../transformers";
export interface TimelineExpansionState {
@@ -10,25 +10,29 @@ export interface TimelineExpansionState {
/**
* Manages expansion state for the timeline.
* Auto-collapses when streaming completes and syncs parallel tab selection.
* Auto-collapses when streaming completes or message content starts, and syncs parallel tab selection.
*/
export function useTimelineExpansion(
stopPacketSeen: boolean,
lastTurnGroup: TurnGroup | undefined
lastTurnGroup: TurnGroup | undefined,
hasDisplayContent: boolean = false
): TimelineExpansionState {
const [isExpanded, setIsExpanded] = useState(!stopPacketSeen);
const [isExpanded, setIsExpanded] = useState(false);
const [parallelActiveTab, setParallelActiveTab] = useState<string>("");
const userHasToggled = useRef(false);
const handleToggle = useCallback(() => {
userHasToggled.current = true;
setIsExpanded((prev) => !prev);
}, []);
// Auto-collapse when streaming completes
// Auto-collapse when streaming completes or message content starts
// BUT respect user intent - if they've manually toggled, don't auto-collapse
useEffect(() => {
if (stopPacketSeen) {
if ((stopPacketSeen || hasDisplayContent) && !userHasToggled.current) {
setIsExpanded(false);
}
}, [stopPacketSeen]);
}, [stopPacketSeen, hasDisplayContent]);
// Sync active tab when parallel turn group changes
useEffect(() => {

View File

@@ -5,8 +5,8 @@ import {
SearchToolPacket,
StopReason,
CustomToolStart,
} from "@/app/chat/services/streamingModels";
import { constructCurrentSearchState } from "@/app/chat/message/messageComponents/timeline/renderers/search/searchStateUtils";
} from "@/app/app/services/streamingModels";
import { constructCurrentSearchState } from "@/app/app/message/messageComponents/timeline/renderers/search/searchStateUtils";
export interface TimelineHeaderResult {
headerText: string;
@@ -20,12 +20,18 @@ export interface TimelineHeaderResult {
*/
export function useTimelineHeader(
turnGroups: TurnGroup[],
stopReason?: StopReason
stopReason?: StopReason,
isGeneratingImage?: boolean
): TimelineHeaderResult {
return useMemo(() => {
const hasPackets = turnGroups.length > 0;
const userStopped = stopReason === StopReason.USER_CANCELLED;
// If generating image with no tool packets, show image generation header
if (isGeneratingImage && !hasPackets) {
return { headerText: "Generating image...", hasPackets, userStopped };
}
if (!hasPackets) {
return { headerText: "Thinking...", hasPackets, userStopped };
}
@@ -53,14 +59,19 @@ export function useTimelineHeader(
const searchState = constructCurrentSearchState(
currentStep.packets as SearchToolPacket[]
);
const headerText = searchState.isInternetSearch
? "Searching web"
: "Searching internal documents";
let headerText: string;
if (searchState.hasResults && !searchState.isInternetSearch) {
headerText = "Reading";
} else {
headerText = searchState.isInternetSearch
? "Searching the web"
: "Searching internal documents";
}
return { headerText, hasPackets, userStopped };
}
if (packetType === PacketType.FETCH_TOOL_START) {
return { headerText: "Opening URLs", hasPackets, userStopped };
return { headerText: "Reading", hasPackets, userStopped };
}
if (packetType === PacketType.PYTHON_TOOL_START) {
@@ -93,5 +104,5 @@ export function useTimelineHeader(
}
return { headerText: "Thinking...", hasPackets, userStopped };
}, [turnGroups, stopReason]);
}, [turnGroups, stopReason, isGeneratingImage]);
}

Some files were not shown because too many files have changed in this diff Show More