Compare commits

...

40 Commits

Author SHA1 Message Date
SubashMohan
65f3d3ad5c feat(chat): add support for search tool packets in AgentTimeline and enhance StepContainer with custom collapsed icon functionality 2026-01-22 19:18:24 +05:30
SubashMohan
727c7d708a fix(chat): update title in ReasoningRenderer and improve content size display in ExpandableTextDisplay for clarity 2026-01-22 18:53:17 +05:30
SubashMohan
5415a73d9e fix(chat): update step headers in AgentTimeline and StoppedHeader components for clarity 2026-01-22 18:49:08 +05:30
SubashMohan
239bc2d6a6 don't display 2 steps for open url 2026-01-22 18:45:59 +05:30
SubashMohan
3bcad459ab feat(chat): streamline DeepResearchPlanRenderer and ResearchAgentRenderer by removing unnecessary animation logic and adding streaming support for improved content display 2026-01-22 18:37:57 +05:30
SubashMohan
ae4a2aa8c9 feat(chat): enhance timeline components with horizontal scroll functionality and improved layout for better user experience 2026-01-22 18:37:25 +05:30
SubashMohan
384fbea1ec feat(chat): remove unique tool names tracking and integrate generated image count in timeline components for enhanced display and performance 2026-01-22 17:11:54 +05:30
SubashMohan
8fca361ace feat(chat): enhance timeline components with render type overrides for improved content display and add support for research agent packets 2026-01-22 15:56:31 +05:30
SubashMohan
c97d245862 feat(chat): implement conditional rendering for BlinkingDot in timeline renderers and enhance step visibility based on stopPacketSeen state 2026-01-22 14:42:50 +05:30
SubashMohan
5bb338e80e feat(chat): enhance ResearchAgentRenderer with compact mode support and conditional rendering for improved content display 2026-01-22 12:45:28 +05:30
SubashMohan
9565dbfe24 feat(chat): add hover state to timeline components for enhanced user interaction and visual feedback 2026-01-22 12:15:27 +05:30
SubashMohan
a5710bd14c feat(chat): add loading states to timeline tabs for improved user feedback during processing 2026-01-22 11:21:45 +05:30
SubashMohan
fdd720b124 refactor(chat): reorganize message component imports and update MessageList to use AgentMessage for improved structure and clarity 2026-01-21 23:10:34 +05:30
SubashMohan
42a3dff826 feat(chat): implement SourceTag component for improved citation handling and display in chat messages 2026-01-21 19:27:28 +05:30
SubashMohan
33f9d644fe feat(chat): replace icon components with SVG counterparts for improved visual consistency in tool display 2026-01-21 18:56:13 +05:30
SubashMohan
194d583e91 feat(chat): refactor AgentTimeline component by introducing TimelineContainer and TimelineContentRow for improved structure and readability 2026-01-21 18:30:48 +05:30
SubashMohan
2a6ebdd83f feat(chat): enhance timeline components with collapsible tabs and improved styling for better user experience 2026-01-21 18:24:37 +05:30
SubashMohan
5736a226e6 feat(chat): introduce image generation tracking and update related components for enhanced user feedback 2026-01-21 17:21:53 +05:30
SubashMohan
a39a4910c6 feat(chat): add chatSessionId to ChatInputBar and ChatPage for improved context handling 2026-01-21 15:55:31 +05:30
SubashMohan
18dd1cacc6 feat(chat): add streaming start time retrieval and update processing duration calculation 2026-01-21 14:55:31 +05:30
SubashMohan
a7ae573810 feat(chat): implement streaming duration tracking and enhance message components for improved UX 2026-01-21 14:41:23 +05:30
SubashMohan
7692de095a feat(chat): integrate processing duration into chat message components and interfaces 2026-01-21 12:51:53 +05:30
SubashMohan
8c7897e054 feat(chat): add processing duration tracking to chat messages 2026-01-21 11:55:17 +05:30
SubashMohan
cf5fe7e44a feat(chat): enhance timeline transformers and packet processing with detailed examples 2026-01-21 11:28:51 +05:30
SubashMohan
b4edf5442c feat(chat): add AgentMessage and MessageToolbar 2026-01-20 18:05:43 +05:30
SubashMohan
54065aef75 feat(chat): update CustomToolRenderer and ImageToolRenderer to support compact rendering mode 2026-01-20 17:53:30 +05:30
SubashMohan
eb1028d255 feat(chat): enhance message handling and introduce ResearchAgentRenderer for deep research visualization 2026-01-20 17:06:03 +05:30
SubashMohan
10eeaf3852 add deepresearch renderer 2026-01-20 17:00:24 +05:30
SubashMohan
5f97b23499 feat(chat): implement PythonToolRenderer and ReasoningRenderer for enhanced code execution and reasoning visualization 2026-01-20 16:54:16 +05:30
SubashMohan
56c63e8868 feat(chat): add AgentTimeline component for tool execution visualization 2026-01-20 16:45:18 +05:30
SubashMohan
0e139413d1 feat(chat): add timeline container components and parallel tabs 2026-01-20 16:31:35 +05:30
SubashMohan
6837417d76 add search and fetch renderers 2026-01-20 16:23:46 +05:30
SubashMohan
34320c98c9 feat: implement timeline headers and hooks for enhanced message streaming 2026-01-20 16:00:15 +05:30
SubashMohan
54e110ef13 organise files 2026-01-20 15:40:30 +05:30
SubashMohan
9d41d40c24 fix: use ValidSources enum consistently in SourceTag
Replace string literal "web" with ValidSources.Web enum for type safety
and consistency with codebase patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:59:42 +05:30
SubashMohan
d71ec1d730 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-20 14:59:35 +05:30
SubashMohan
626d5aed7e fix: update SourceInfo interface to remove redundant sourceType value 2026-01-20 14:36:58 +05:30
SubashMohan
c76514d8e5 feat: add SourceTag and SourceTagDetailsCard components for source display functionality 2026-01-20 14:35:33 +05:30
SubashMohan
b8b3b3e5f0 refactor: optimize Tabs context value and update ExpandableTextDisplay maxLines prop type 2026-01-20 14:01:35 +05:30
SubashMohan
a62239e477 feat: add new icons and components for enhanced UI functionality 2026-01-20 13:50:29 +05:30
73 changed files with 6064 additions and 253 deletions

View File

@@ -0,0 +1,27 @@
"""add processing_duration_seconds to chat_message
Revision ID: 9d1543a37106
Revises: 8b5ce697290e
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 = "8b5ce697290e"
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

@@ -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
@@ -313,6 +314,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
@@ -603,6 +605,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
@@ -723,6 +726,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()
@@ -765,6 +769,7 @@ def llm_loop_completion_handle(
db_session=db_session,
assistant_message=assistant_message,
is_clarification=state_container.is_clarification,
processing_start_time=processing_start_time,
)

View File

@@ -1,4 +1,5 @@
import json
import time
from sqlalchemy.orm import Session
@@ -158,6 +159,7 @@ def save_chat_turn(
db_session: Session,
assistant_message: ChatMessage,
is_clarification: bool = False,
processing_start_time: float | None = None,
) -> None:
"""
Save a chat turn by populating the assistant_message and creating related entities.
@@ -186,12 +188,19 @@ def save_chat_turn(
db_session: Database session for persistence
assistant_message: The ChatMessage object to populate (should already exist in DB)
is_clarification: Whether this assistant message is a clarification question (deep research flow)
processing_start_time: Start time (from time.monotonic()) for calculating processing duration
"""
# 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
# Calculate and set processing duration if start time was provided
if processing_start_time is not None:
assistant_message.processing_duration_seconds = (
time.monotonic() - processing_start_time
)
# 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

@@ -2157,6 +2157,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

@@ -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

@@ -572,7 +572,7 @@ def translate_assistant_message_to_packets(
# Determine stop reason - check if message indicates user cancelled
stop_reason: str | None = None
if chat_message.message:
if "Generation was stopped" in chat_message.message:
if "generation was stopped" in chat_message.message.lower():
stop_reason = "user_cancelled"
# Add overall stop packet at the end

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgBranch = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M4.75001 5C5.71651 5 6.50001 4.2165 6.50001 3.25C6.50001 2.2835 5.7165 1.5 4.75 1.5C3.78351 1.5 3.00001 2.2835 3.00001 3.25C3.00001 4.2165 3.78351 5 4.75001 5ZM4.75001 5L4.75001 6.24999M4.75 11C3.7835 11 3 11.7835 3 12.75C3 13.7165 3.7835 14.5 4.75 14.5C5.7165 14.5 6.5 13.7165 6.5 12.75C6.5 11.7835 5.71649 11 4.75 11ZM4.75 11L4.75001 6.24999M10.5 8.74997C10.5 9.71646 11.2835 10.5 12.25 10.5C13.2165 10.5 14 9.71646 14 8.74997C14 7.78347 13.2165 7 12.25 7C11.2835 7 10.5 7.78347 10.5 8.74997ZM10.5 8.74997L7.25001 8.74999C5.8693 8.74999 4.75001 7.6307 4.75001 6.24999"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgBranch;

View File

@@ -0,0 +1,16 @@
import type { IconProps } from "@opal/types";
const SvgCircle = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<circle cx="8" cy="8" r="4" strokeWidth={1.5} />
</svg>
);
export default SvgCircle;

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgDownload = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M14 10V12.6667C14 13.3929 13.3929 14 12.6667 14H3.33333C2.60711 14 2 13.3929 2 12.6667V10M4.66667 6.66667L8 10M8 10L11.3333 6.66667M8 10L8 2"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgDownload;

View File

@@ -24,6 +24,7 @@ export { default as SvgBookOpen } from "@opal/icons/book-open";
export { default as SvgBooksLineSmall } from "@opal/icons/books-line-small";
export { default as SvgBooksStackSmall } from "@opal/icons/books-stack-small";
export { default as SvgBracketCurly } from "@opal/icons/bracket-curly";
export { default as SvgBranch } from "@opal/icons/branch";
export { default as SvgBubbleText } from "@opal/icons/bubble-text";
export { default as SvgCalendar } from "@opal/icons/calendar";
export { default as SvgCheck } from "@opal/icons/check";
@@ -36,6 +37,7 @@ export { default as SvgChevronLeft } from "@opal/icons/chevron-left";
export { default as SvgChevronRight } from "@opal/icons/chevron-right";
export { default as SvgChevronUp } from "@opal/icons/chevron-up";
export { default as SvgChevronUpSmall } from "@opal/icons/chevron-up-small";
export { default as SvgCircle } from "@opal/icons/circle";
export { default as SvgClaude } from "@opal/icons/claude";
export { default as SvgClipboard } from "@opal/icons/clipboard";
export { default as SvgClock } from "@opal/icons/clock";
@@ -46,6 +48,7 @@ export { default as SvgCopy } from "@opal/icons/copy";
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
export { default as SvgCpu } from "@opal/icons/cpu";
export { default as SvgDevKit } from "@opal/icons/dev-kit";
export { default as SvgDownload } from "@opal/icons/download";
export { default as SvgDownloadCloud } from "@opal/icons/download-cloud";
export { default as SvgEdit } from "@opal/icons/edit";
export { default as SvgEditBig } from "@opal/icons/edit-big";
@@ -132,6 +135,7 @@ export { default as SvgStep3End } from "@opal/icons/step3-end";
export { default as SvgStop } from "@opal/icons/stop";
export { default as SvgStopCircle } from "@opal/icons/stop-circle";
export { default as SvgSun } from "@opal/icons/sun";
export { default as SvgTerminal } from "@opal/icons/terminal";
export { default as SvgTerminalSmall } from "@opal/icons/terminal-small";
export { default as SvgTextLinesSmall } from "@opal/icons/text-lines-small";
export { default as SvgThumbsDown } from "@opal/icons/thumbs-down";

View File

@@ -0,0 +1,22 @@
import type { IconProps } from "@opal/types";
const SvgTerminal = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M2.66667 11.3333L6.66667 7.33331L2.66667 3.33331M8.00001 12.6666H13.3333"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgTerminal;

View File

@@ -31,6 +31,7 @@ import { fetchBedrockModels } from "../utils";
import Separator from "@/refresh-components/Separator";
import Text from "@/refresh-components/texts/Text";
import Tabs from "@/refresh-components/Tabs";
import { cn } from "@/lib/utils";
export const BEDROCK_PROVIDER_NAME = "bedrock";
const BEDROCK_DISPLAY_NAME = "AWS Bedrock";
@@ -135,7 +136,7 @@ function BedrockFormInternals({
!formikProps.values.custom_config?.AWS_REGION_NAME || !isAuthComplete;
return (
<Form className={LLM_FORM_CLASS_NAME}>
<Form className={cn(LLM_FORM_CLASS_NAME, "w-full")}>
<DisplayNameField disabled={!!existingLlmProvider} />
<SelectorFormField
@@ -176,7 +177,7 @@ function BedrockFormInternals({
</Tabs.Content>
<Tabs.Content value={AUTH_METHOD_ACCESS_KEY}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 w-full">
<TextFormField
name={FIELD_AWS_ACCESS_KEY_ID}
label="AWS Access Key ID"
@@ -191,7 +192,7 @@ function BedrockFormInternals({
</Tabs.Content>
<Tabs.Content value={AUTH_METHOD_LONG_TERM_API_KEY}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 w-full">
<PasswordInputTypeInField
name={FIELD_AWS_BEARER_TOKEN_BEDROCK}
label="AWS Bedrock Long-term API Key"

View File

@@ -751,6 +751,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
: projectContextTokenCount
}
availableContextTokens={availableContextTokens}
chatSessionId={currentChatSessionId}
selectedAssistant={selectedAssistant || liveAssistant}
handleFileUpload={handleMessageSpecificFileUpload}
setPresentingDocument={setPresentingDocument}

View File

@@ -98,6 +98,7 @@ export interface ChatInputBarProps {
chatState: ChatState;
currentSessionFileTokenCount: number;
availableContextTokens: number;
chatSessionId?: string | null;
// assistants
selectedAssistant: MinimalPersonaSnapshot | undefined;
@@ -127,6 +128,7 @@ const ChatInputBar = React.memo(
chatState,
currentSessionFileTokenCount,
availableContextTokens,
chatSessionId,
// assistants
selectedAssistant,
@@ -514,7 +516,9 @@ const ChatInputBar = React.memo(
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="How can I help you today"
placeholder={
chatSessionId ? "Reply..." : "How can I help you today"
}
value={message}
onKeyDown={(event) => {
if (

View File

@@ -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,6 +871,17 @@ export function useChatController({
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
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
@@ -885,6 +908,7 @@ export function useChatController({
toolCall: null,
parentNodeId: parentMessage?.nodeId || SYSTEM_NODE_ID,
packets: [],
packetCount: 0,
},
{
nodeId: initialAssistantNode.nodeId,
@@ -894,6 +918,7 @@ export function useChatController({
toolCall: null,
parentNodeId: initialUserNode.nodeId,
packets: [],
packetCount: 0,
stackTrace: stackTrace,
errorCode: errorCode,
isRetryable: isRetryable,
@@ -906,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,6 +139,7 @@ export interface Message {
// new gen
packets: Packet[];
packetCount?: number; // Tracks packet count for React memo comparison (avoids reading from mutated array)
// cached values for easy access
documents?: OnyxDocument[] | null;
@@ -146,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 {
@@ -195,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,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,232 @@
import React, { useRef, RefObject, useMemo } from "react";
import { Packet, StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState } from "@/app/chat/message/messageComponents/interfaces";
import { FeedbackType } from "@/app/chat/interfaces";
import { handleCopy } from "@/app/chat/message/copyingUtils";
import { useMessageSwitching } from "@/app/chat/message/messageComponents/hooks/useMessageSwitching";
import { RendererComponent } from "@/app/chat/message/messageComponents/renderMessageComponent";
import { usePacketProcessor } from "@/app/chat/message/messageComponents/timeline/hooks/usePacketProcessor";
import MessageToolbar from "@/app/chat/message/messageComponents/MessageToolbar";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import { Message } from "@/app/chat/interfaces";
import Text from "@/refresh-components/texts/Text";
import { AgentTimeline } from "@/app/chat/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,
} = usePacketProcessor(rawPackets, nodeId);
// 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={toolTurnGroups}
chatState={effectiveChatState}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
hasDisplayContent={displayGroups.length > 0}
processingDurationSeconds={processingDurationSeconds}
isGeneratingImage={isGeneratingImage}
generatedImageCount={generatedImageCount}
/>
{/* 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>);
}
}}
>
{displayGroups.length > 0 && (
<div ref={finalAnswerRef}>
{displayGroups.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 === displayGroups.length - 1) {
onRenderComplete();
}
}}
animate={false}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{({ content }) => <div>{content}</div>}
</RendererComponent>
))}
{/* Show stopped message when user cancelled and no display content */}
{displayGroups.length === 0 &&
stopReason === StopReason.USER_CANCELLED && (
<Text as="p" secondaryBody text04>
User has stopped generation
</Text>
)}
</div>
)}
</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/chat/services/streamingModels";
import { FeedbackType } from "@/app/chat/interfaces";
import { OnyxDocument } from "@/lib/search/interfaces";
import { TooltipGroup } from "@/components/tooltip/CustomTooltip";
import {
useChatSessionStore,
useDocumentSidebarVisible,
useSelectedNodeForDocDisplay,
} from "@/app/chat/stores/useChatSessionStore";
import {
handleCopy,
convertMarkdownTablesToTsv,
} from "@/app/chat/message/copyingUtils";
import { getTextContent } from "@/app/chat/services/packetUtils";
import { removeThinkingTokens } from "@/app/chat/services/thinkingTokens";
import MessageSwitcher from "@/app/chat/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/chat/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}
showDetailsCard
/>
);
});
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

@@ -11,6 +11,7 @@ import { CitationMap } from "../../interfaces";
export enum RenderType {
HIGHLIGHT = "highlight",
FULL = "full",
COMPACT = "compact",
}
export interface FullChatState {
@@ -35,6 +36,9 @@ export interface RendererResult {
// used for things that should just show text w/o an icon or header
// e.g. ReasoningRenderer
expandedText?: JSX.Element;
// Whether this renderer supports compact mode (collapse button shown only when true)
supportsCompact?: boolean;
}
export type MessageRenderer<
@@ -48,5 +52,9 @@ export type MessageRenderer<
animate: boolean;
stopPacketSeen: boolean;
stopReason?: StopReason;
/** Whether this is the last step in the timeline (for connector line decisions) */
isLastStep?: boolean;
/** Hover state from parent */
isHover?: boolean;
children: (result: RendererResult) => JSX.Element;
}>;

View File

@@ -1,4 +1,4 @@
import React, { JSX } from "react";
import React, { JSX, memo } from "react";
import {
ChatPacket,
Packet,
@@ -14,13 +14,13 @@ import {
} 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 { SearchToolRenderer } from "./timeline/renderers/search/SearchToolRenderer";
// Different types of chat packets using discriminated unions
export interface GroupedPackets {
@@ -122,17 +122,8 @@ export function findRenderer(
return null;
}
// React component wrapper that directly uses renderer components
export function RendererComponent({
packets,
chatState,
onComplete,
animate,
stopPacketSeen,
stopReason,
useShortRenderer = false,
children,
}: {
// Props interface for RendererComponent
interface RendererComponentProps {
packets: Packet[];
chatState: FullChatState;
onComplete: () => void;
@@ -141,7 +132,35 @@ export function RendererComponent({
stopReason?: StopReason;
useShortRenderer?: boolean;
children: (result: RendererResult) => 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 const RendererComponent = memo(function RendererComponent({
packets,
chatState,
onComplete,
animate,
stopPacketSeen,
stopReason,
useShortRenderer = false,
children,
}: RendererComponentProps) {
const RendererFn = findRenderer({ packets });
const renderType = useShortRenderer ? RenderType.HIGHLIGHT : RenderType.FULL;
@@ -162,4 +181,4 @@ export function RendererComponent({
{children}
</RendererFn>
);
}
}, areRendererPropsEqual);

View File

@@ -68,10 +68,11 @@ export const CustomToolRenderer: MessageRenderer<CustomToolPacket, {}> = ({
const icon = FiTool;
if (renderType === RenderType.HIGHLIGHT) {
if (renderType === RenderType.COMPACT) {
return children({
icon,
status: status,
supportsCompact: true,
content: (
<div className="text-sm text-muted-foreground">
{isRunning && `${toolName} running...`}
@@ -84,6 +85,7 @@ export const CustomToolRenderer: MessageRenderer<CustomToolPacket, {}> = ({
return children({
icon,
status,
supportsCompact: true,
content: (
<div className="flex flex-col gap-3">
{/* File responses */}

View File

@@ -72,6 +72,7 @@ export const ImageToolRenderer: MessageRenderer<
return children({
icon: FiImage,
status: "Generating images...",
supportsCompact: false,
content: (
<div className="flex flex-col">
<div>
@@ -89,6 +90,7 @@ export const ImageToolRenderer: MessageRenderer<
status: `Generated ${images.length} image${
images.length !== 1 ? "s" : ""
}`,
supportsCompact: false,
content: (
<div className="flex flex-col my-1">
{images.length > 0 ? (
@@ -122,6 +124,7 @@ export const ImageToolRenderer: MessageRenderer<
return children({
icon: FiImage,
status: status,
supportsCompact: false,
content: <div></div>,
});
}
@@ -131,6 +134,7 @@ export const ImageToolRenderer: MessageRenderer<
return children({
icon: FiImage,
status: "Generating image...",
supportsCompact: false,
content: (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-0.5">
@@ -154,6 +158,7 @@ export const ImageToolRenderer: MessageRenderer<
return children({
icon: FiImage,
status: "Image generation failed",
supportsCompact: false,
content: (
<div className="text-sm text-red-600 dark:text-red-400">
Image generation failed
@@ -166,6 +171,7 @@ export const ImageToolRenderer: MessageRenderer<
return children({
icon: FiImage,
status: `Generated ${images.length} image${images.length > 1 ? "s" : ""}`,
supportsCompact: false,
content: (
<div className="text-sm text-muted-foreground">
Generated {images.length} image
@@ -178,6 +184,7 @@ export const ImageToolRenderer: MessageRenderer<
return children({
icon: FiImage,
status: "Image generation",
supportsCompact: false,
content: (
<div className="text-sm text-muted-foreground">Image generation</div>
),

View File

@@ -0,0 +1,531 @@
"use client";
import React, { FunctionComponent, useMemo, useCallback } from "react";
import { StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState, RenderType } from "../interfaces";
import { TurnGroup, TransformedStep } from "./transformers";
import { cn } from "@/lib/utils";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import { SvgCheckCircle, SvgStopCircle } from "@opal/icons";
import { IconProps } from "@opal/types";
import {
TimelineRendererComponent,
TimelineRendererResult,
} from "./TimelineRendererComponent";
import Text from "@/refresh-components/texts/Text";
import { ParallelTimelineTabs } from "./ParallelTimelineTabs";
import { StepContainer } from "./StepContainer";
import {
useTimelineExpansion,
useTimelineMetrics,
useTimelineHeader,
} from "@/app/chat/message/messageComponents/timeline/hooks";
import {
isResearchAgentPackets,
isSearchToolPackets,
stepSupportsCompact,
} from "@/app/chat/message/messageComponents/timeline/packetHelpers";
import {
StreamingHeader,
CollapsedHeader,
ExpandedHeader,
StoppedHeader,
ParallelStreamingHeader,
} from "@/app/chat/message/messageComponents/timeline/headers";
import { useStreamingStartTime } from "@/app/chat/stores/useChatSessionStore";
// =============================================================================
// TimelineStep Component - Memoized to prevent re-renders
// =============================================================================
interface TimelineStepProps {
step: TransformedStep;
chatState: FullChatState;
stopPacketSeen: boolean;
stopReason?: StopReason;
isLastStep: boolean;
isFirstStep: boolean;
isSingleStep: boolean;
}
//will be removed on cleanup
const noopCallback = () => {};
const TimelineStep = React.memo(function TimelineStep({
step,
chatState,
stopPacketSeen,
stopReason,
isLastStep,
isFirstStep,
isSingleStep,
}: TimelineStepProps) {
// Memoize packet type checks to avoid recomputing on every render
const isResearchAgent = useMemo(
() => isResearchAgentPackets(step.packets),
[step.packets]
);
const isSearchTool = useMemo(
() => isSearchToolPackets(step.packets),
[step.packets]
);
// Stable render callback - doesn't need to change between renders
const renderStep = useCallback(
({
icon,
status,
content,
isExpanded,
onToggle,
isLastStep: rendererIsLastStep,
supportsCompact,
}: TimelineRendererResult) =>
isResearchAgent ? (
content
) : (
<StepContainer
stepIcon={icon as FunctionComponent<IconProps> | undefined}
header={status}
isExpanded={isExpanded}
onToggle={onToggle}
collapsible={true}
supportsCompact={supportsCompact}
isLastStep={rendererIsLastStep}
isFirstStep={isFirstStep}
hideHeader={isSingleStep}
collapsedIcon={
isSearchTool ? (icon as FunctionComponent<IconProps>) : undefined
}
>
{content}
</StepContainer>
),
[isResearchAgent, isSearchTool, step.packets, isFirstStep, isSingleStep]
);
return (
<TimelineRendererComponent
packets={step.packets}
chatState={chatState}
onComplete={noopCallback}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
defaultExpanded={true}
isLastStep={isLastStep}
>
{renderStep}
</TimelineRendererComponent>
);
});
// =============================================================================
// 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>
);
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>
);
// =============================================================================
// 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;
}
/**
* 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
);
}
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,
}: 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,
lastStepSupportsCompact,
} = useTimelineMetrics(turnGroups, userStopped);
// Expansion state management
const { isExpanded, handleToggle, parallelActiveTab, setParallelActiveTab } =
useTimelineExpansion(stopPacketSeen, lastTurnGroup, hasDisplayContent);
// Streaming duration tracking
const streamingStartTime = useStreamingStartTime();
// Stable callbacks to avoid creating new functions on every render
const noopComplete = useCallback(() => {}, []);
const renderContentOnly = useCallback(
({ content }: TimelineRendererResult) => content,
[]
);
// 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 parallelActiveStepSupportsCompact = useMemo(() => {
if (!parallelActiveStep) return false;
return stepSupportsCompact(parallelActiveStep.packets);
}, [parallelActiveStep]);
// Collapsed streaming: show compact content below header (only during tool execution)
const showCollapsedCompact =
!stopPacketSeen &&
!hasDisplayContent &&
!isExpanded &&
lastStep &&
!lastTurnGroup?.isParallel &&
lastStepSupportsCompact;
// Parallel tabs in header only when collapsed (expanded view has tabs in content)
// Only show during tool execution, not when message content is streaming
const showParallelTabs =
!stopPacketSeen &&
!hasDisplayContent &&
!isExpanded &&
lastTurnGroup?.isParallel &&
lastTurnGroup.steps.length > 0;
// Collapsed parallel compact content
const showCollapsedParallel =
showParallelTabs && !isExpanded && parallelActiveStepSupportsCompact;
// Done indicator conditions
const showDoneIndicator =
stopPacketSeen && isExpanded && !userStopped && !lastStepIsResearchAgent;
// Header selection based on state
// Show streaming headers only when actively executing tools (no message content yet)
// Once hasDisplayContent is true, switch to collapsed/expanded headers
// Exception: show streaming header when generating image (even if hasDisplayContent is true)
const renderHeader = () => {
if (!stopPacketSeen && (!hasDisplayContent || isGeneratingImage)) {
if (showParallelTabs && lastTurnGroup) {
return (
<ParallelStreamingHeader
steps={lastTurnGroup.steps}
activeTab={parallelActiveTab}
onTabChange={setParallelActiveTab}
collapsible={collapsible}
isExpanded={isExpanded}
onToggle={handleToggle}
/>
);
}
return (
<StreamingHeader
headerText={headerText}
collapsible={collapsible}
buttonTitle={buttonTitle}
isExpanded={isExpanded}
onToggle={handleToggle}
streamingStartTime={streamingStartTime}
/>
);
}
if (userStopped) {
return (
<StoppedHeader
totalSteps={totalSteps}
collapsible={collapsible}
isExpanded={isExpanded}
onToggle={handleToggle}
/>
);
}
if (!isExpanded) {
return (
<CollapsedHeader
totalSteps={totalSteps}
collapsible={collapsible}
onToggle={handleToggle}
processingDurationSeconds={processingDurationSeconds}
generatedImageCount={generatedImageCount}
/>
);
}
return (
<ExpandedHeader
collapsible={collapsible}
onToggle={handleToggle}
processingDurationSeconds={processingDurationSeconds}
/>
);
};
// Empty state: no packets, still streaming, and not stopped
if (!hasPackets && !hasDisplayContent && !stopPacketSeen) {
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 (hasDisplayContent && !hasPackets && !isGeneratingImage) {
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",
((!stopPacketSeen && !hasDisplayContent) || isExpanded) &&
"bg-background-tint-00 rounded-t-12",
!isExpanded &&
!showCollapsedCompact &&
!showCollapsedParallel &&
"rounded-b-12"
)}
>
{renderHeader()}
</div>
}
>
{/* Collapsed streaming view - single step compact mode */}
{showCollapsedCompact && lastStep && (
<TimelineContentRow className="bg-background-tint-00 rounded-b-12 px-2 pb-2">
<TimelineRendererComponent
key={`${lastStep.key}-compact`}
packets={lastStep.packets}
chatState={chatState}
onComplete={noopComplete}
animate={true}
stopPacketSeen={false}
stopReason={stopReason}
defaultExpanded={false}
renderTypeOverride={
lastStepIsResearchAgent ? RenderType.HIGHLIGHT : undefined
}
isLastStep={true}
>
{renderContentOnly}
</TimelineRendererComponent>
</TimelineContentRow>
)}
{/* Collapsed streaming view - parallel tools compact mode */}
{showCollapsedParallel && parallelActiveStep && (
<TimelineContentRow className="bg-background-tint-00 rounded-b-12 px-2 pb-2">
<TimelineRendererComponent
key={`${parallelActiveStep.key}-compact`}
packets={parallelActiveStep.packets}
chatState={chatState}
onComplete={noopComplete}
animate={true}
stopPacketSeen={false}
stopReason={stopReason}
defaultExpanded={false}
renderTypeOverride={RenderType.HIGHLIGHT}
isLastStep={true}
>
{renderContentOnly}
</TimelineRendererComponent>
</TimelineContentRow>
)}
{/* Expanded timeline view */}
{isExpanded && (
<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 &&
!showDoneIndicator &&
!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}
/>
);
})
)
)}
{/* Done indicator */}
{stopPacketSeen && isExpanded && !userStopped && (
<StepContainer
stepIcon={SvgCheckCircle}
header="Generate Answer"
isLastStep={true}
isFirstStep={false}
>
{null}
</StepContainer>
)}
{/* Stopped indicator */}
{stopPacketSeen && isExpanded && userStopped && (
<StepContainer
stepIcon={SvgStopCircle}
header="Stopped"
isLastStep={true}
isFirstStep={false}
>
{null}
</StepContainer>
)}
</div>
)}
</TimelineContainer>
);
}, areAgentTimelinePropsEqual);
export default AgentTimeline;

View File

@@ -0,0 +1,200 @@
"use client";
import React, {
useState,
useMemo,
useCallback,
FunctionComponent,
} from "react";
import { cn } from "@/lib/utils";
import { StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState } from "../interfaces";
import { TurnGroup } from "./transformers";
import {
getToolName,
getToolIcon,
isToolComplete,
} from "../toolDisplayHelpers";
import {
TimelineRendererComponent,
TimelineRendererResult,
} from "./TimelineRendererComponent";
import Tabs from "@/refresh-components/Tabs";
import { SvgBranch, SvgFold, SvgExpand } from "@opal/icons";
import { StepContainer } from "./StepContainer";
import { isResearchAgentPackets } from "@/app/chat/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(
({
icon,
status,
content,
isExpanded,
onToggle,
isLastStep,
isHover,
}: TimelineRendererResult) =>
isResearchAgentPackets(activeStep?.packets ?? []) ? (
content
) : (
<StepContainer
stepIcon={icon as FunctionComponent<IconProps> | undefined}
header={status}
isExpanded={isExpanded}
onToggle={onToggle}
collapsible={true}
isLastStep={isLastStep}
isFirstStep={false}
isHover={isHover}
>
{content}
</StepContainer>
),
[activeStep?.packets]
);
return (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div
className="flex flex-col w-full"
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<div className="flex w-full">
{/* 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="flex-1 min-w-0">
<Tabs.List
variant="pill"
enableScrollArrows
className={cn(
isHover && "bg-background-tint-02",
"transition-colors duration-200"
)}
rightContent={
hasResearchAgent ? (
<IconButton
tertiary
onClick={handleToggle}
icon={isExpanded ? SvgFold : SvgExpand}
/>
) : undefined
}
>
{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

@@ -0,0 +1,134 @@
import React, { FunctionComponent } from "react";
import { cn } from "@/lib/utils";
import { SvgFold, SvgExpand } from "@opal/icons";
import Button from "@/refresh-components/buttons/Button";
import IconButton from "@/refresh-components/buttons/IconButton";
import { IconProps } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
export interface StepContainerProps {
/** Main content */
children?: React.ReactNode;
/** Step icon component */
stepIcon?: FunctionComponent<IconProps>;
/** Header left slot */
header?: React.ReactNode;
/** Button title for toggle */
buttonTitle?: string;
/** Controlled expanded state */
isExpanded?: boolean;
/** Toggle callback */
onToggle?: () => void;
/** Whether collapse control is shown */
collapsible?: boolean;
/** Collapse button shown only when renderer supports compact mode */
supportsCompact?: boolean;
/** Additional class names */
className?: string;
/** Last step (no bottom connector) */
isLastStep?: boolean;
/** First step (top padding instead of connector) */
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>;
}
/** Visual wrapper for timeline steps - icon, connector line, header, and content */
export function StepContainer({
children,
stepIcon: StepIconComponent,
header,
buttonTitle,
isExpanded = true,
onToggle,
collapsible = true,
supportsCompact = false,
isLastStep = false,
isFirstStep = false,
className,
hideHeader = false,
isHover = false,
collapsedIcon: CollapsedIconComponent,
}: StepContainerProps) {
const showCollapseControls = collapsible && supportsCompact && onToggle;
return (
<div className={cn("flex w-full", className)}>
<div
className={cn(
"flex flex-col items-center w-9 pt-1",
isFirstStep && "pt-2"
)}
>
{/* Icon */}
{!hideHeader && StepIconComponent && (
<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={cn(
"w-px h-full bg-border-01",
isHover && "bg-border-04"
)}
/>
)}
</div>
<div
className={cn(
"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 pl-2 pr-1 h-8">
{header && (
<Text as="p" mainUiMuted text03>
{header}
</Text>
)}
{showCollapseControls &&
(buttonTitle ? (
<Button
tertiary
onClick={onToggle}
rightIcon={
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
}
>
{buttonTitle}
</Button>
) : (
<IconButton
tertiary
onClick={onToggle}
icon={
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
}
/>
))}
</div>
)}
<div className="px-2 pb-2">{children}</div>
</div>
</div>
);
}
export default StepContainer;

View File

@@ -0,0 +1,130 @@
"use client";
import React, { useState, useCallback, JSX } from "react";
import { Packet, StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState, RenderType, RendererResult } from "../interfaces";
import { findRenderer } from "../renderMessageComponent";
/** Extended result that includes collapse state */
export interface TimelineRendererResult extends RendererResult {
/** Current expanded state */
isExpanded: boolean;
/** Toggle callback */
onToggle: () => void;
/** Current render type */
renderType: RenderType;
/** Whether this is the last step (passed through from props) */
isLastStep: boolean;
/** Hover state from parent */
isHover: boolean;
}
export interface TimelineRendererComponentProps {
/** Packets to render */
packets: Packet[];
/** Chat state for rendering */
chatState: FullChatState;
/** Completion callback */
onComplete: () => void;
/** Whether to animate streaming */
animate: boolean;
/** Whether stop packet has been seen */
stopPacketSeen: boolean;
/** Reason for stopping */
stopReason?: StopReason;
/** Initial expanded state */
defaultExpanded?: boolean;
/** Whether this is the last step in the timeline (for connector line decisions) */
isLastStep?: boolean;
/** 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 */
children: (result: TimelineRendererResult) => JSX.Element;
}
// Custom comparison function to prevent unnecessary re-renders
// Only re-render if meaningful changes occur
function arePropsEqual(
prev: TimelineRendererComponentProps,
next: TimelineRendererComponentProps
): boolean {
return (
prev.packets.length === next.packets.length &&
prev.stopPacketSeen === next.stopPacketSeen &&
prev.stopReason === next.stopReason &&
prev.animate === next.animate &&
prev.isLastStep === next.isLastStep &&
prev.isHover === next.isHover &&
prev.defaultExpanded === next.defaultExpanded &&
prev.renderTypeOverride === next.renderTypeOverride
// Skipping chatState (memoized upstream)
);
}
export const TimelineRendererComponent = React.memo(
function TimelineRendererComponent({
packets,
chatState,
onComplete,
animate,
stopPacketSeen,
stopReason,
defaultExpanded = true,
isLastStep,
isHover = false,
renderTypeOverride,
children,
}: TimelineRendererComponentProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const RendererFn = findRenderer({ packets });
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,
isHover,
});
}
return (
<RendererFn
packets={packets as any}
state={chatState}
onComplete={onComplete}
animate={animate}
renderType={renderType}
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,
isHover,
})
}
</RendererFn>
);
},
arePropsEqual
);

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,
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

@@ -0,0 +1,39 @@
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>
{durationText}
</Text>
{collapsible && (
<IconButton
tertiary
onClick={onToggle}
icon={SvgFold}
aria-label="Collapse timeline"
aria-expanded={true}
/>
)}
</>
);
});

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

@@ -0,0 +1,38 @@
import React from "react";
import { SvgFold, SvgExpand } from "@opal/icons";
import Button from "@/refresh-components/buttons/Button";
import Text from "@/refresh-components/texts/Text";
export interface StoppedHeaderProps {
totalSteps: number;
collapsible: boolean;
isExpanded: boolean;
onToggle: () => void;
}
/** Header when user stopped/cancelled */
export const StoppedHeader = React.memo(function StoppedHeader({
totalSteps,
collapsible,
isExpanded,
onToggle,
}: StoppedHeaderProps) {
return (
<>
<Text as="p" mainUiAction text03>
Interrupted Thinking
</Text>
{collapsible && totalSteps > 0 && (
<Button
tertiary
onClick={onToggle}
rightIcon={isExpanded ? SvgFold : SvgExpand}
aria-label={isExpanded ? "Collapse timeline" : "Expand timeline"}
aria-expanded={isExpanded}
>
{totalSteps} {totalSteps === 1 ? "step" : "steps"}
</Button>
)}
</>
);
});

View File

@@ -0,0 +1,72 @@
import React from "react";
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";
import { formatDurationSeconds } from "@/lib/time";
export interface StreamingHeaderProps {
headerText: string;
collapsible: boolean;
buttonTitle?: string;
isExpanded: boolean;
onToggle: () => void;
streamingStartTime?: number;
}
/** Header during streaming - shimmer text with current activity */
export const StreamingHeader = React.memo(function StreamingHeader({
headerText,
collapsible,
buttonTitle,
isExpanded,
onToggle,
streamingStartTime,
}: StreamingHeaderProps) {
const elapsedSeconds = useStreamingDuration(true, streamingStartTime);
const showElapsedTime =
isExpanded && streamingStartTime && elapsedSeconds > 0;
return (
<>
<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>
{collapsible &&
(buttonTitle ? (
<Button
tertiary
onClick={onToggle}
rightIcon={isExpanded ? SvgFold : SvgExpand}
aria-expanded={isExpanded}
>
{buttonTitle}
</Button>
) : showElapsedTime ? (
<Button
tertiary
onClick={onToggle}
rightIcon={SvgFold}
aria-label="Collapse timeline"
aria-expanded={true}
>
{formatDurationSeconds(elapsedSeconds)}
</Button>
) : (
<IconButton
tertiary
onClick={onToggle}
icon={isExpanded ? SvgFold : SvgExpand}
aria-label={isExpanded ? "Collapse timeline" : "Expand timeline"}
aria-expanded={isExpanded}
/>
))}
</>
);
});

View File

@@ -0,0 +1,14 @@
export { StreamingHeader } from "./StreamingHeader";
export type { StreamingHeaderProps } from "./StreamingHeader";
export { CollapsedHeader } from "./CollapsedHeader";
export type { CollapsedHeaderProps } from "./CollapsedHeader";
export { ExpandedHeader } from "./ExpandedHeader";
export type { ExpandedHeaderProps } from "./ExpandedHeader";
export { StoppedHeader } from "./StoppedHeader";
export type { StoppedHeaderProps } from "./StoppedHeader";
export { ParallelStreamingHeader } from "./ParallelStreamingHeader";
export type { ParallelStreamingHeaderProps } from "./ParallelStreamingHeader";

View File

@@ -0,0 +1,13 @@
export { useTimelineExpansion } from "./useTimelineExpansion";
export type { TimelineExpansionState } from "./useTimelineExpansion";
export { useTimelineMetrics } from "./useTimelineMetrics";
export type { TimelineMetrics } from "./useTimelineMetrics";
export { usePacketProcessor } from "./usePacketProcessor";
export type { UsePacketProcessorResult } from "./usePacketProcessor";
export { useTimelineHeader } from "./useTimelineHeader";
export type { TimelineHeaderResult } from "./useTimelineHeader";
export { useStreamingDuration } from "./useStreamingDuration";

View File

@@ -0,0 +1,456 @@
import {
Packet,
PacketType,
StreamingCitation,
StopReason,
CitationInfo,
SearchToolDocumentsDelta,
FetchToolDocuments,
TopLevelBranching,
Stop,
ImageGenerationToolDelta,
} from "@/app/chat/services/streamingModels";
import { CitationMap } from "@/app/chat/interfaces";
import { OnyxDocument } from "@/lib/search/interfaces";
import {
isActualToolCallPacket,
isToolPacket,
isDisplayPacket,
} from "@/app/chat/services/packetUtils";
import { parseToolKey } from "@/app/chat/message/messageComponents/toolDisplayHelpers";
// Re-export parseToolKey for consumers that import from this module
export { parseToolKey };
// ============================================================================
// Types
// ============================================================================
export interface ProcessorState {
nodeId: number;
lastProcessedIndex: number;
// Citations
citations: StreamingCitation[];
seenCitationDocIds: Set<string>;
citationMap: CitationMap;
// Documents
documentMap: Map<string, OnyxDocument>;
// Packet grouping
groupedPacketsMap: Map<string, Packet[]>;
seenGroupKeys: Set<string>;
groupKeysWithSectionEnd: Set<string>;
expectedBranches: Map<number, number>;
// Pre-categorized groups (populated during packet processing)
toolGroupKeys: Set<string>;
displayGroupKeys: Set<string>;
// Image generation status
isGeneratingImage: boolean;
generatedImageCount: number;
// Streaming status
finalAnswerComing: boolean;
stopPacketSeen: boolean;
stopReason: StopReason | undefined;
// Result arrays (built at end of processPackets)
toolGroups: GroupedPacket[];
potentialDisplayGroups: GroupedPacket[];
}
export interface GroupedPacket {
turn_index: number;
tab_index: number;
packets: Packet[];
}
// ============================================================================
// State Creation
// ============================================================================
export function createInitialState(nodeId: number): ProcessorState {
return {
nodeId,
lastProcessedIndex: 0,
citations: [],
seenCitationDocIds: new Set(),
citationMap: {},
documentMap: new Map(),
groupedPacketsMap: new Map(),
seenGroupKeys: new Set(),
groupKeysWithSectionEnd: new Set(),
expectedBranches: new Map(),
toolGroupKeys: new Set(),
displayGroupKeys: new Set(),
isGeneratingImage: false,
generatedImageCount: 0,
finalAnswerComing: false,
stopPacketSeen: false,
stopReason: undefined,
toolGroups: [],
potentialDisplayGroups: [],
};
}
// ============================================================================
// Helper Functions
// ============================================================================
function getGroupKey(packet: Packet): string {
const turnIndex = packet.placement.turn_index;
const tabIndex = packet.placement.tab_index ?? 0;
return `${turnIndex}-${tabIndex}`;
}
function injectSectionEnd(state: ProcessorState, groupKey: string): void {
if (state.groupKeysWithSectionEnd.has(groupKey)) {
return; // Already has SECTION_END
}
const { turn_index, tab_index } = parseToolKey(groupKey);
const syntheticPacket: Packet = {
placement: { turn_index, tab_index },
obj: { type: PacketType.SECTION_END },
};
const existingGroup = state.groupedPacketsMap.get(groupKey);
if (existingGroup) {
existingGroup.push(syntheticPacket);
}
state.groupKeysWithSectionEnd.add(groupKey);
}
/**
* Content packet types that indicate a group has meaningful content to display
*/
const CONTENT_PACKET_TYPES_SET = new Set<PacketType>([
PacketType.MESSAGE_START,
PacketType.SEARCH_TOOL_START,
PacketType.IMAGE_GENERATION_TOOL_START,
PacketType.PYTHON_TOOL_START,
PacketType.CUSTOM_TOOL_START,
PacketType.FETCH_TOOL_START,
PacketType.REASONING_START,
PacketType.DEEP_RESEARCH_PLAN_START,
PacketType.RESEARCH_AGENT_START,
]);
function hasContentPackets(packets: Packet[]): boolean {
return packets.some((packet) =>
CONTENT_PACKET_TYPES_SET.has(packet.obj.type as PacketType)
);
}
/**
* Packet types that indicate final answer content is coming
*/
const FINAL_ANSWER_PACKET_TYPES_SET = new Set<PacketType>([
PacketType.MESSAGE_START,
PacketType.MESSAGE_DELTA,
PacketType.IMAGE_GENERATION_TOOL_START,
PacketType.IMAGE_GENERATION_TOOL_DELTA,
PacketType.PYTHON_TOOL_START,
PacketType.PYTHON_TOOL_DELTA,
]);
// ============================================================================
// Packet Handlers
// ============================================================================
function handleTopLevelBranching(state: ProcessorState, packet: Packet): void {
const branchingPacket = packet.obj as TopLevelBranching;
state.expectedBranches.set(
packet.placement.turn_index,
branchingPacket.num_parallel_branches
);
}
function handleTurnTransition(state: ProcessorState, packet: Packet): void {
const currentTurnIndex = packet.placement.turn_index;
// Get all previous turn indices from seen group keys
const previousTurnIndices = new Set(
Array.from(state.seenGroupKeys).map((key) => parseToolKey(key).turn_index)
);
const isNewTurnIndex = !previousTurnIndices.has(currentTurnIndex);
// If we see a new turn_index (not just tab_index), inject SECTION_END for previous groups
if (isNewTurnIndex && state.seenGroupKeys.size > 0) {
state.seenGroupKeys.forEach((prevGroupKey) => {
if (!state.groupKeysWithSectionEnd.has(prevGroupKey)) {
injectSectionEnd(state, prevGroupKey);
}
});
}
}
function handleCitationPacket(state: ProcessorState, packet: Packet): void {
if (packet.obj.type !== PacketType.CITATION_INFO) {
return;
}
const citationInfo = packet.obj as CitationInfo;
// Add to citation map immediately for rendering
state.citationMap[citationInfo.citation_number] = citationInfo.document_id;
// Also add to citations array for CitedSourcesToggle (deduplicated)
if (!state.seenCitationDocIds.has(citationInfo.document_id)) {
state.seenCitationDocIds.add(citationInfo.document_id);
state.citations.push({
citation_num: citationInfo.citation_number,
document_id: citationInfo.document_id,
});
}
}
function handleDocumentPacket(state: ProcessorState, packet: Packet): void {
if (packet.obj.type === PacketType.SEARCH_TOOL_DOCUMENTS_DELTA) {
const docDelta = packet.obj as SearchToolDocumentsDelta;
if (docDelta.documents) {
for (const doc of docDelta.documents) {
if (doc.document_id) {
state.documentMap.set(doc.document_id, doc);
}
}
}
} else if (packet.obj.type === PacketType.FETCH_TOOL_DOCUMENTS) {
const fetchDocuments = packet.obj as FetchToolDocuments;
if (fetchDocuments.documents) {
for (const doc of fetchDocuments.documents) {
if (doc.document_id) {
state.documentMap.set(doc.document_id, doc);
}
}
}
}
}
function handleStreamingStatusPacket(
state: ProcessorState,
packet: Packet
): void {
// Check if final answer is coming
if (FINAL_ANSWER_PACKET_TYPES_SET.has(packet.obj.type as PacketType)) {
state.finalAnswerComing = true;
}
}
function handleStopPacket(state: ProcessorState, packet: Packet): void {
if (packet.obj.type !== PacketType.STOP || state.stopPacketSeen) {
return;
}
state.stopPacketSeen = true;
// Extract and store the stop reason
const stopPacket = packet.obj as Stop;
state.stopReason = stopPacket.stop_reason;
// Inject SECTION_END for all group keys that don't have one
state.seenGroupKeys.forEach((groupKey) => {
if (!state.groupKeysWithSectionEnd.has(groupKey)) {
injectSectionEnd(state, groupKey);
}
});
}
function handleToolAfterMessagePacket(
state: ProcessorState,
packet: Packet
): void {
// Handles case where we get a Message packet from Claude, and then tool
// calling packets. We use isActualToolCallPacket instead of isToolPacket
// to exclude reasoning packets - reasoning is just the model thinking,
// not an actual tool call that would produce new content.
if (
state.finalAnswerComing &&
!state.stopPacketSeen &&
isActualToolCallPacket(packet)
) {
state.finalAnswerComing = false;
}
}
function addPacketToGroup(
state: ProcessorState,
packet: Packet,
groupKey: string
): void {
const existingGroup = state.groupedPacketsMap.get(groupKey);
if (existingGroup) {
existingGroup.push(packet);
} else {
state.groupedPacketsMap.set(groupKey, [packet]);
}
}
// ============================================================================
// Main Processing Function
// ============================================================================
function processPacket(state: ProcessorState, packet: Packet): void {
if (!packet) return;
// Handle TopLevelBranching packets - these tell us how many parallel branches to expect
if (packet.obj.type === PacketType.TOP_LEVEL_BRANCHING) {
handleTopLevelBranching(state, packet);
// Don't add this packet to any group, it's just metadata
return;
}
// Handle turn transitions (inject SECTION_END for previous groups)
handleTurnTransition(state, packet);
// Track group key
const groupKey = getGroupKey(packet);
state.seenGroupKeys.add(groupKey);
// Track SECTION_END and ERROR packets (both indicate completion)
if (
packet.obj.type === PacketType.SECTION_END ||
packet.obj.type === PacketType.ERROR
) {
state.groupKeysWithSectionEnd.add(groupKey);
}
// Check if this is the first packet in the group (before adding)
const existingGroup = state.groupedPacketsMap.get(groupKey);
const isFirstPacket = !existingGroup;
// Add packet to group
addPacketToGroup(state, packet, groupKey);
// Categorize on first packet of each group
if (isFirstPacket) {
if (isToolPacket(packet, false)) {
state.toolGroupKeys.add(groupKey);
}
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
handleCitationPacket(state, packet);
handleDocumentPacket(state, packet);
handleStreamingStatusPacket(state, packet);
handleStopPacket(state, packet);
handleToolAfterMessagePacket(state, packet);
}
export function processPackets(
state: ProcessorState,
rawPackets: Packet[]
): ProcessorState {
// Handle reset (packets array shrunk - upstream replaced with shorter list)
if (state.lastProcessedIndex > rawPackets.length) {
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];
if (packet) {
processPacket(state, packet);
}
}
state.lastProcessedIndex = rawPackets.length;
// 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;
}
/**
* 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,
keys: Set<string>
): GroupedPacket[] {
return Array.from(keys)
.map((key) => {
const { turn_index, tab_index } = parseToolKey(key);
const packets = state.groupedPacketsMap.get(key);
// Spread to create new array reference - ensures React detects changes for re-renders
return packets ? { turn_index, tab_index, packets: [...packets] } : null;
})
.filter(
(g): g is GroupedPacket => g !== null && hasContentPackets(g.packets)
)
.sort((a, b) => {
if (a.turn_index !== b.turn_index) {
return a.turn_index - b.turn_index;
}
return a.tab_index - b.tab_index;
});
}

View File

@@ -0,0 +1,155 @@
import { useRef, useState, useMemo, useCallback } from "react";
import {
Packet,
StreamingCitation,
StopReason,
} from "@/app/chat/services/streamingModels";
import { CitationMap } from "@/app/chat/interfaces";
import { OnyxDocument } from "@/lib/search/interfaces";
import {
ProcessorState,
GroupedPacket,
createInitialState,
processPackets,
} from "@/app/chat/message/messageComponents/timeline/hooks/packetProcessor";
import {
transformPacketGroups,
groupStepsByTurn,
TurnGroup,
} from "@/app/chat/message/messageComponents/timeline/transformers";
export interface UsePacketProcessorResult {
// Data
toolGroups: GroupedPacket[];
displayGroups: GroupedPacket[];
toolTurnGroups: TurnGroup[];
citations: StreamingCitation[];
citationMap: CitationMap;
documentMap: Map<string, OnyxDocument>;
// Status (derived from packets)
stopPacketSeen: boolean;
stopReason: StopReason | undefined;
hasSteps: boolean;
expectedBranchesPerTurn: Map<number, number>;
isGeneratingImage: boolean;
generatedImageCount: number;
// Completion: stopPacketSeen && renderComplete
isComplete: boolean;
// Callbacks
onRenderComplete: () => void;
markAllToolsDisplayed: () => void;
}
/**
* Hook for processing streaming packets in AgentMessage.
*
* Architecture:
* - Processor state in ref: incremental processing, synchronous, no double render
* - Only true UI state: renderComplete (set by callback), forceShowAnswer (override)
* - Everything else derived from packets
*
* Key insight: finalAnswerComing and stopPacketSeen are DERIVED from packets,
* not independent state. Only renderComplete needs useState.
*/
export function usePacketProcessor(
rawPackets: Packet[],
nodeId: number
): UsePacketProcessorResult {
// Processor in ref: incremental, synchronous, no double render
const stateRef = useRef<ProcessorState>(createInitialState(nodeId));
// Only TRUE UI state: "has renderer finished?"
const [renderComplete, setRenderComplete] = useState(false);
// Optional override to force showing answer
const [forceShowAnswer, setForceShowAnswer] = useState(false);
// Reset on nodeId change
if (stateRef.current.nodeId !== nodeId) {
stateRef.current = createInitialState(nodeId);
setRenderComplete(false);
setForceShowAnswer(false);
}
// Track for transition detection
const prevLastProcessed = stateRef.current.lastProcessedIndex;
const prevFinalAnswerComing = stateRef.current.finalAnswerComing;
// Detect stream reset (packets shrunk)
if (prevLastProcessed > rawPackets.length) {
stateRef.current = createInitialState(nodeId);
setRenderComplete(false);
setForceShowAnswer(false);
}
// Process packets synchronously (incremental) - only if new packets arrived
if (rawPackets.length > stateRef.current.lastProcessedIndex) {
stateRef.current = processPackets(stateRef.current, rawPackets);
}
// Reset renderComplete on tool-after-message transition
if (prevFinalAnswerComing && !stateRef.current.finalAnswerComing) {
setRenderComplete(false);
}
// Access state directly (result arrays are built in processPackets)
const state = stateRef.current;
// Derive displayGroups (not state!)
const effectiveFinalAnswerComing = state.finalAnswerComing || forceShowAnswer;
const displayGroups = useMemo(() => {
if (effectiveFinalAnswerComing || state.toolGroups.length === 0) {
return state.potentialDisplayGroups;
}
return [];
}, [
effectiveFinalAnswerComing,
state.toolGroups.length,
state.potentialDisplayGroups,
]);
// Transform toolGroups to timeline format
const toolTurnGroups = useMemo(() => {
const allSteps = transformPacketGroups(state.toolGroups);
return groupStepsByTurn(allSteps);
}, [state.toolGroups]);
// Callback reads from ref: always current value, no ref needed in component
const onRenderComplete = useCallback(() => {
if (stateRef.current.finalAnswerComing) {
setRenderComplete(true);
}
}, []);
const markAllToolsDisplayed = useCallback(() => {
setForceShowAnswer(true);
}, []);
return {
// Data
toolGroups: state.toolGroups,
displayGroups,
toolTurnGroups,
citations: state.citations,
citationMap: state.citationMap,
documentMap: state.documentMap,
// Status (derived from packets)
stopPacketSeen: state.stopPacketSeen,
stopReason: state.stopReason,
hasSteps: toolTurnGroups.length > 0,
expectedBranchesPerTurn: state.expectedBranches,
isGeneratingImage: state.isGeneratingImage,
generatedImageCount: state.generatedImageCount,
// Completion: stopPacketSeen && renderComplete
isComplete: state.stopPacketSeen && renderComplete,
// Callbacks
onRenderComplete,
markAllToolsDisplayed,
};
}

View File

@@ -0,0 +1,53 @@
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())
* @returns Elapsed seconds since streaming started
*/
export function useStreamingDuration(
isStreaming: boolean,
startTime: number | undefined
): number {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const rafRef = useRef<number | null>(null);
const lastElapsedRef = useRef<number>(0);
useEffect(() => {
if (!isStreaming || !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;
}
};
}, [isStreaming, startTime]);
return elapsedSeconds;
}

View File

@@ -0,0 +1,51 @@
import { useState, useEffect, useCallback } from "react";
import { TurnGroup } from "../transformers";
export interface TimelineExpansionState {
isExpanded: boolean;
handleToggle: () => void;
parallelActiveTab: string;
setParallelActiveTab: (tab: string) => void;
}
/**
* Manages expansion state for the timeline.
* Auto-collapses when streaming completes or message content starts, and syncs parallel tab selection.
*/
export function useTimelineExpansion(
stopPacketSeen: boolean,
lastTurnGroup: TurnGroup | undefined,
hasDisplayContent: boolean = false
): TimelineExpansionState {
const [isExpanded, setIsExpanded] = useState(false);
const [parallelActiveTab, setParallelActiveTab] = useState<string>("");
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
// Auto-collapse when streaming completes or message content starts
useEffect(() => {
if (stopPacketSeen || hasDisplayContent) {
setIsExpanded(false);
}
}, [stopPacketSeen, hasDisplayContent]);
// Sync active tab when parallel turn group changes
useEffect(() => {
if (lastTurnGroup?.isParallel && lastTurnGroup.steps.length > 0) {
const validTabs = lastTurnGroup.steps.map((s) => s.key);
const firstStep = lastTurnGroup.steps[0];
if (firstStep && !validTabs.includes(parallelActiveTab)) {
setParallelActiveTab(firstStep.key);
}
}
}, [lastTurnGroup, parallelActiveTab]);
return {
isExpanded,
handleToggle,
parallelActiveTab,
setParallelActiveTab,
};
}

View File

@@ -0,0 +1,103 @@
import { useMemo } from "react";
import { TurnGroup } from "../transformers";
import {
PacketType,
SearchToolPacket,
StopReason,
CustomToolStart,
} from "@/app/chat/services/streamingModels";
import { constructCurrentSearchState } from "@/app/chat/message/messageComponents/timeline/renderers/search/searchStateUtils";
export interface TimelineHeaderResult {
headerText: string;
hasPackets: boolean;
userStopped: boolean;
}
/**
* Hook that determines timeline header state based on current activity.
* Returns header text, whether there are packets, and whether user stopped.
*/
export function useTimelineHeader(
turnGroups: TurnGroup[],
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 };
}
// Get the last (current) turn group
const currentTurn = turnGroups[turnGroups.length - 1];
if (!currentTurn) {
return { headerText: "Thinking...", hasPackets, userStopped };
}
const currentStep = currentTurn.steps[0];
if (!currentStep?.packets?.length) {
return { headerText: "Thinking...", hasPackets, userStopped };
}
const firstPacket = currentStep.packets[0];
if (!firstPacket) {
return { headerText: "Thinking...", hasPackets, userStopped };
}
const packetType = firstPacket.obj.type;
// Determine header based on packet type
if (packetType === PacketType.SEARCH_TOOL_START) {
const searchState = constructCurrentSearchState(
currentStep.packets as SearchToolPacket[]
);
const headerText = searchState.isInternetSearch
? "Searching web"
: "Searching internal documents";
return { headerText, hasPackets, userStopped };
}
if (packetType === PacketType.FETCH_TOOL_START) {
return { headerText: "Opening URLs", hasPackets, userStopped };
}
if (packetType === PacketType.PYTHON_TOOL_START) {
return { headerText: "Executing code", hasPackets, userStopped };
}
if (packetType === PacketType.IMAGE_GENERATION_TOOL_START) {
return { headerText: "Generating images", hasPackets, userStopped };
}
if (packetType === PacketType.CUSTOM_TOOL_START) {
const toolName = (firstPacket.obj as CustomToolStart).tool_name;
return {
headerText: toolName ? `Executing ${toolName}` : "Executing tool",
hasPackets,
userStopped,
};
}
if (packetType === PacketType.REASONING_START) {
return { headerText: "Thinking", hasPackets, userStopped };
}
if (packetType === PacketType.DEEP_RESEARCH_PLAN_START) {
return { headerText: "Generating plan", hasPackets, userStopped };
}
if (packetType === PacketType.RESEARCH_AGENT_START) {
return { headerText: "Researching", hasPackets, userStopped };
}
return { headerText: "Thinking...", hasPackets, userStopped };
}, [turnGroups, stopReason, isGeneratingImage]);
}

View File

@@ -0,0 +1,55 @@
import { useMemo } from "react";
import {
TurnGroup,
TransformedStep,
} from "@/app/chat/message/messageComponents/timeline/transformers";
import {
isResearchAgentPackets,
stepSupportsCompact,
} from "@/app/chat/message/messageComponents/timeline/packetHelpers";
export interface TimelineMetrics {
totalSteps: number;
isSingleStep: boolean;
lastTurnGroup: TurnGroup | undefined;
lastStep: TransformedStep | undefined;
lastStepIsResearchAgent: boolean;
lastStepSupportsCompact: boolean;
}
/**
* Memoizes derived metrics from turn groups to avoid recomputation on every render.
* Single-pass computation where possible for performance with large packet counts.
*/
export function useTimelineMetrics(
turnGroups: TurnGroup[],
userStopped: boolean
): TimelineMetrics {
return useMemo(() => {
// Compute in single pass
let totalSteps = 0;
for (const tg of turnGroups) {
totalSteps += tg.steps.length;
}
const lastTurnGroup = turnGroups[turnGroups.length - 1];
const lastStep = lastTurnGroup?.steps[lastTurnGroup.steps.length - 1];
// Analyze last step packets once
const lastStepIsResearchAgent = lastStep
? isResearchAgentPackets(lastStep.packets)
: false;
const lastStepSupportsCompact = lastStep
? stepSupportsCompact(lastStep.packets)
: false;
return {
totalSteps,
isSingleStep: totalSteps === 1 && !userStopped,
lastTurnGroup,
lastStep,
lastStepIsResearchAgent,
lastStepSupportsCompact,
};
}, [turnGroups, userStopped]);
}

View File

@@ -0,0 +1,24 @@
import { Packet, PacketType } from "@/app/chat/services/streamingModels";
// Packet types with renderers supporting compact mode
export const COMPACT_SUPPORTED_PACKET_TYPES = new Set<PacketType>([
PacketType.SEARCH_TOOL_START,
PacketType.FETCH_TOOL_START,
PacketType.PYTHON_TOOL_START,
PacketType.CUSTOM_TOOL_START,
PacketType.RESEARCH_AGENT_START,
]);
// Check if packets belong to a research agent (handles its own Done indicator)
export const isResearchAgentPackets = (packets: Packet[]): boolean =>
packets.some((p) => p.obj.type === PacketType.RESEARCH_AGENT_START);
// Check if packets belong to a search tool
export const isSearchToolPackets = (packets: Packet[]): boolean =>
packets.some((p) => p.obj.type === PacketType.SEARCH_TOOL_START);
// Check if step supports compact rendering mode
export const stepSupportsCompact = (packets: Packet[]): boolean =>
packets.some((p) =>
COMPACT_SUPPORTED_PACKET_TYPES.has(p.obj.type as PacketType)
);

View File

@@ -0,0 +1,199 @@
import { useEffect, useMemo } from "react";
import {
PacketType,
PythonToolPacket,
PythonToolStart,
PythonToolDelta,
SectionEnd,
} from "@/app/chat/services/streamingModels";
import {
MessageRenderer,
RenderType,
} from "@/app/chat/message/messageComponents/interfaces";
import { CodeBlock } from "@/app/chat/message/CodeBlock";
import hljs from "highlight.js/lib/core";
import python from "highlight.js/lib/languages/python";
import { SvgTerminal } from "@opal/icons";
import FadingEdgeContainer from "@/refresh-components/FadingEdgeContainer";
// Register Python language for highlighting
hljs.registerLanguage("python", python);
// Component to render syntax-highlighted Python code
function HighlightedPythonCode({ code }: { code: string }) {
const highlightedHtml = useMemo(() => {
try {
return hljs.highlight(code, { language: "python" }).value;
} catch {
return code;
}
}, [code]);
return (
<span
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
className="hljs"
/>
);
}
// Helper function to construct current Python execution state
function constructCurrentPythonState(packets: PythonToolPacket[]) {
const pythonStart = packets.find(
(packet) => packet.obj.type === PacketType.PYTHON_TOOL_START
)?.obj as PythonToolStart | null;
const pythonDeltas = packets
.filter((packet) => packet.obj.type === PacketType.PYTHON_TOOL_DELTA)
.map((packet) => packet.obj as PythonToolDelta);
const pythonEnd = packets.find(
(packet) =>
packet.obj.type === PacketType.SECTION_END ||
packet.obj.type === PacketType.ERROR
)?.obj as SectionEnd | null;
const code = pythonStart?.code || "";
const stdout = pythonDeltas
.map((delta) => delta?.stdout || "")
.filter((s) => s)
.join("");
const stderr = pythonDeltas
.map((delta) => delta?.stderr || "")
.filter((s) => s)
.join("");
const fileIds = pythonDeltas.flatMap((delta) => delta?.file_ids || []);
const isExecuting = pythonStart && !pythonEnd;
const isComplete = pythonStart && pythonEnd;
const hasError = stderr.length > 0;
return {
code,
stdout,
stderr,
fileIds,
isExecuting,
isComplete,
hasError,
};
}
export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
packets,
onComplete,
renderType,
children,
}) => {
const { code, stdout, stderr, fileIds, isExecuting, isComplete, hasError } =
constructCurrentPythonState(packets);
useEffect(() => {
if (isComplete) {
onComplete();
}
}, [isComplete, onComplete]);
const status = useMemo(() => {
if (isExecuting) {
return "Executing Python code...";
}
if (hasError) {
return "Python execution failed";
}
if (isComplete) {
return "Python execution completed";
}
return "Python execution";
}, [isComplete, isExecuting, hasError]);
// Shared content for all states - used by both FULL and compact modes
const content = (
<div className="flex flex-col mb-1 space-y-2">
{/* Loading indicator when executing */}
{isExecuting && (
<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>Running code...</span>
</div>
)}
{/* Code block */}
{code && (
<div className="prose max-w-full">
<CodeBlock className="language-python" codeText={code.trim()}>
<HighlightedPythonCode code={code.trim()} />
</CodeBlock>
</div>
)}
{/* Output */}
{stdout && (
<div className="rounded-md bg-gray-100 dark:bg-gray-800 p-3">
<div className="text-xs font-semibold mb-1 text-gray-600 dark:text-gray-400">
Output:
</div>
<pre className="text-sm whitespace-pre-wrap font-mono text-gray-900 dark:text-gray-100">
{stdout}
</pre>
</div>
)}
{/* Error */}
{stderr && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-3 border border-red-200 dark:border-red-800">
<div className="text-xs font-semibold mb-1 text-red-600 dark:text-red-400">
Error:
</div>
<pre className="text-sm whitespace-pre-wrap font-mono text-red-900 dark:text-red-100">
{stderr}
</pre>
</div>
)}
{/* File count */}
{fileIds.length > 0 && (
<div className="text-sm text-gray-600 dark:text-gray-400">
Generated {fileIds.length} file{fileIds.length !== 1 ? "s" : ""}
</div>
)}
{/* No output fallback - only when complete with no output */}
{isComplete && !stdout && !stderr && (
<div className="py-2 text-center text-gray-500 dark:text-gray-400">
<SvgTerminal className="w-4 h-4 mx-auto mb-1 opacity-50" />
<p className="text-xs">No output</p>
</div>
)}
</div>
);
// FULL mode: render content directly
if (renderType === RenderType.FULL) {
return children({
icon: SvgTerminal,
status,
content,
supportsCompact: true,
});
}
// Compact mode: wrap content in FadeDiv
return children({
icon: SvgTerminal,
status,
supportsCompact: true,
content: (
<FadingEdgeContainer direction="bottom" className="h-24">
{content}
</FadingEdgeContainer>
),
});
};

View File

@@ -0,0 +1,79 @@
import React, { useMemo, useCallback } from "react";
import { FiList } from "react-icons/fi";
import {
DeepResearchPlanPacket,
PacketType,
} from "@/app/chat/services/streamingModels";
import {
MessageRenderer,
FullChatState,
} from "@/app/chat/message/messageComponents/interfaces";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ExpandableTextDisplay from "@/refresh-components/texts/ExpandableTextDisplay";
import { mutedTextMarkdownComponents } from "@/app/chat/message/messageComponents/timeline/renderers/sharedMarkdownComponents";
/**
* Renderer for deep research plan packets.
* Streams the research plan content with a list icon.
* Collapsible and auto-collapses when plan generation is complete.
*/
export const DeepResearchPlanRenderer: MessageRenderer<
DeepResearchPlanPacket,
FullChatState
> = ({
packets,
state,
onComplete,
renderType,
animate,
stopPacketSeen,
children,
}) => {
// Check if plan generation is complete (has SECTION_END)
const isComplete = packets.some((p) => p.obj.type === PacketType.SECTION_END);
// Get the full content from all packets
const fullContent = useMemo(
() =>
packets
.map((packet) => {
if (packet.obj.type === PacketType.DEEP_RESEARCH_PLAN_DELTA) {
return packet.obj.content;
}
return "";
})
.join(""),
[packets]
);
// Markdown renderer callback for ExpandableTextDisplay
const renderMarkdown = useCallback(
(text: string) => (
<MinimalMarkdown
content={text}
components={mutedTextMarkdownComponents}
/>
),
[]
);
const statusText = isComplete ? "Generated plan" : "Generating plan";
const planContent = (
<ExpandableTextDisplay
title="Deep research plan"
content={fullContent}
maxLines={5}
renderContent={renderMarkdown}
isStreaming={!isComplete && !stopPacketSeen}
/>
);
return children({
icon: FiList,
status: statusText,
content: planContent,
expandedText: planContent,
});
};

View File

@@ -0,0 +1,345 @@
import React, { useMemo, useCallback, FunctionComponent } from "react";
import { SvgCircle, SvgCheckCircle, SvgBookOpen } from "@opal/icons";
import { IconProps } from "@opal/types";
import {
PacketType,
Packet,
ResearchAgentPacket,
ResearchAgentStart,
IntermediateReportDelta,
} from "@/app/chat/services/streamingModels";
import {
MessageRenderer,
FullChatState,
RenderType,
} from "@/app/chat/message/messageComponents/interfaces";
import { getToolName } from "@/app/chat/message/messageComponents/toolDisplayHelpers";
import { StepContainer } from "@/app/chat/message/messageComponents/timeline/StepContainer";
import { TimelineRendererComponent } from "@/app/chat/message/messageComponents/timeline/TimelineRendererComponent";
import ExpandableTextDisplay from "@/refresh-components/texts/ExpandableTextDisplay";
import Text from "@/refresh-components/texts/Text";
import { useMarkdownRenderer } from "@/app/chat/message/messageComponents/markdownUtils";
interface NestedToolGroup {
sub_turn_index: number;
toolType: string;
status: string;
isComplete: boolean;
packets: Packet[];
}
/**
* ResearchAgentRenderer - Renders research agent steps in deep research
*
* Segregates packets by tool and uses StepContainer + TimelineRendererComponent.
*
* RenderType modes:
* - FULL: Shows all nested tool groups, research task, and report. Headers passed as `status` prop.
* Used when step is expanded in timeline.
* - COMPACT: Shows only the latest active item (tool or report). Header passed as `status` prop.
* Used when step is collapsed in timeline, still wrapped in StepContainer.
* - HIGHLIGHT: Shows only the latest active item with header embedded directly in content.
* No StepContainer wrapper. Used for parallel streaming preview.
* Nested tools are rendered with HIGHLIGHT mode recursively.
*/
export const ResearchAgentRenderer: MessageRenderer<
ResearchAgentPacket,
FullChatState
> = ({
packets,
state,
onComplete,
renderType,
stopPacketSeen,
isLastStep = true,
isHover = false,
children,
}) => {
// Extract the research task from the start packet
const startPacket = packets.find(
(p) => p.obj.type === PacketType.RESEARCH_AGENT_START
);
const researchTask = startPacket
? (startPacket.obj as ResearchAgentStart).research_task
: "";
// Separate parent packets from nested tool packets
const { parentPackets, nestedToolGroups } = useMemo(() => {
const parent: Packet[] = [];
const nestedBySubTurn = new Map<number, Packet[]>();
packets.forEach((packet) => {
const subTurnIndex = packet.placement.sub_turn_index;
if (subTurnIndex === undefined || subTurnIndex === null) {
parent.push(packet);
} else {
if (!nestedBySubTurn.has(subTurnIndex)) {
nestedBySubTurn.set(subTurnIndex, []);
}
nestedBySubTurn.get(subTurnIndex)!.push(packet);
}
});
// Convert nested packets to groups with metadata
const groups: NestedToolGroup[] = Array.from(nestedBySubTurn.entries())
.sort(([a], [b]) => a - b)
.map(([subTurnIndex, toolPackets]) => {
const name = getToolName(toolPackets);
const isComplete = toolPackets.some(
(p) =>
p.obj.type === PacketType.SECTION_END ||
p.obj.type === PacketType.REASONING_DONE
);
return {
sub_turn_index: subTurnIndex,
toolType: name,
status: isComplete ? "Complete" : "Running",
isComplete,
packets: toolPackets,
};
});
return { parentPackets: parent, nestedToolGroups: groups };
}, [packets]);
// Filter nested tool groups based on renderType (COMPACT and HIGHLIGHT show only latest)
const visibleNestedToolGroups = useMemo(() => {
if (
(renderType !== RenderType.COMPACT &&
renderType !== RenderType.HIGHLIGHT) ||
nestedToolGroups.length === 0
) {
return nestedToolGroups;
}
// COMPACT/HIGHLIGHT mode: show only the latest group (last in sorted array)
const latestGroup = nestedToolGroups[nestedToolGroups.length - 1];
return latestGroup ? [latestGroup] : [];
}, [renderType, nestedToolGroups]);
// Check completion from parent packets
const isComplete = parentPackets.some(
(p) => p.obj.type === PacketType.SECTION_END
);
// Determine if report is actively streaming
const isReportStreaming = !isComplete && !stopPacketSeen;
// Build report content from parent packets
const fullReportContent = parentPackets
.map((packet) => {
if (packet.obj.type === PacketType.INTERMEDIATE_REPORT_DELTA) {
return (packet.obj as IntermediateReportDelta).content;
}
return "";
})
.join("");
// Condensed modes: show only the currently active/streaming section
const isCompact = renderType === RenderType.COMPACT;
const isHighlight = renderType === RenderType.HIGHLIGHT;
const isCondensedMode = isCompact || isHighlight;
// Report takes priority if it has content (means tools are done, report is streaming)
const showOnlyReport =
isCondensedMode && fullReportContent && visibleNestedToolGroups.length > 0;
const showOnlyTools =
isCondensedMode && !fullReportContent && visibleNestedToolGroups.length > 0;
// Markdown renderer for ExpandableTextDisplay
const { renderedContent } = useMarkdownRenderer(
fullReportContent,
state,
"text-text-03 font-main-ui-body"
);
// Stable callbacks to avoid creating new functions on every render
const noopComplete = useCallback(() => {}, []);
const renderReport = useCallback(() => renderedContent, [renderedContent]);
// HIGHLIGHT mode: return raw content with header embedded in content
if (isHighlight) {
if (showOnlyReport) {
return children({
icon: null,
status: null,
content: (
<div className="flex flex-col">
<Text as="p" text02 className="text-sm mb-1">
Research Report
</Text>
<ExpandableTextDisplay
title="Research Report"
content={fullReportContent}
maxLines={5}
renderContent={renderReport}
isStreaming={isReportStreaming}
/>
</div>
),
supportsCompact: true,
});
}
if (showOnlyTools) {
const latestGroup = visibleNestedToolGroups[0];
if (latestGroup) {
return (
<TimelineRendererComponent
key={latestGroup.sub_turn_index}
packets={latestGroup.packets}
chatState={state}
onComplete={noopComplete}
animate={!stopPacketSeen && !latestGroup.isComplete}
stopPacketSeen={stopPacketSeen}
defaultExpanded={false}
renderTypeOverride={RenderType.HIGHLIGHT}
isLastStep={true}
isHover={isHover}
>
{({ content }) =>
children({
icon: null,
status: null,
content,
supportsCompact: true,
})
}
</TimelineRendererComponent>
);
}
}
// Fallback: research task with header embedded
if (researchTask) {
return children({
icon: null,
status: null,
content: (
<div className="flex flex-col">
<Text as="p" text02 className="text-sm mb-1">
Research Task
</Text>
<div className="text-text-600 text-sm">{researchTask}</div>
</div>
),
supportsCompact: true,
});
}
return children({
icon: null,
status: null,
content: <></>,
supportsCompact: true,
});
}
// Build content using StepContainer pattern
const researchAgentContent = (
<div className="flex flex-col">
{/* Research Task - hidden in compact mode when tools/report are active */}
{researchTask && !showOnlyReport && !showOnlyTools && (
<StepContainer
stepIcon={SvgCircle}
header="Research Task"
collapsible={true}
isLastStep={
!stopPacketSeen &&
nestedToolGroups.length === 0 &&
!fullReportContent &&
!isComplete
}
isHover={isHover}
>
<div className="text-text-600 text-sm">{researchTask}</div>
</StepContainer>
)}
{/* Nested tool calls - hidden when report is streaming in compact mode */}
{!showOnlyReport &&
visibleNestedToolGroups.map((group, index) => {
const isLastNestedStep =
!stopPacketSeen &&
index === visibleNestedToolGroups.length - 1 &&
!fullReportContent &&
!isComplete;
return (
<TimelineRendererComponent
key={group.sub_turn_index}
packets={group.packets}
chatState={state}
onComplete={noopComplete}
animate={!stopPacketSeen && !group.isComplete}
stopPacketSeen={stopPacketSeen}
defaultExpanded={true}
isLastStep={isLastNestedStep}
isHover={isHover}
>
{({
icon,
status,
content,
isExpanded,
onToggle,
isHover,
supportsCompact,
}) => (
<StepContainer
stepIcon={icon as FunctionComponent<IconProps> | undefined}
header={status}
isExpanded={isExpanded}
onToggle={onToggle}
collapsible={true}
isLastStep={isLastNestedStep}
isFirstStep={!researchTask && index === 0}
isHover={isHover}
supportsCompact={supportsCompact}
>
{content}
</StepContainer>
)}
</TimelineRendererComponent>
);
})}
{/* Intermediate report - hidden when tools are active in compact mode */}
{fullReportContent && !showOnlyTools && (
<StepContainer
stepIcon={SvgBookOpen}
header="Research Report"
isLastStep={!stopPacketSeen && !isComplete}
isFirstStep={!researchTask && nestedToolGroups.length === 0}
isHover={isHover}
>
<ExpandableTextDisplay
title="Research Report"
content={fullReportContent}
maxLines={5}
renderContent={renderReport}
isStreaming={isReportStreaming}
/>
</StepContainer>
)}
{/* Done indicator - hidden in compact mode when active content */}
{isComplete && !isLastStep && !showOnlyReport && !showOnlyTools && (
<StepContainer
stepIcon={SvgCheckCircle}
header="Done"
isLastStep={!stopPacketSeen && isLastStep}
isFirstStep={false}
isHover={isHover}
/>
)}
</div>
);
// Return simplified result (no icon, no status)
return children({
icon: null,
status: null,
content: researchAgentContent,
supportsCompact: true,
});
};

View File

@@ -0,0 +1,172 @@
import React from "react";
import { FiLink } from "react-icons/fi";
import { FetchToolPacket } from "@/app/chat/services/streamingModels";
import {
MessageRenderer,
RenderType,
} from "@/app/chat/message/messageComponents/interfaces";
import { BlinkingDot } from "@/app/chat/message/BlinkingDot";
import { OnyxDocument } from "@/lib/search/interfaces";
import { ValidSources } from "@/lib/types";
import { SearchChipList, SourceInfo } from "../search/SearchChipList";
import { getMetadataTags } from "../search";
import {
constructCurrentFetchState,
INITIAL_URLS_TO_SHOW,
URLS_PER_EXPANSION,
} from "./fetchStateUtils";
import Text from "@/refresh-components/texts/Text";
const urlToSourceInfo = (url: string, index: number): SourceInfo => ({
id: `url-${index}`,
title: url,
sourceType: ValidSources.Web,
sourceUrl: url,
});
const documentToSourceInfo = (doc: OnyxDocument): SourceInfo => ({
id: doc.document_id,
title: doc.semantic_identifier || doc.link || "",
sourceType: doc.source_type || ValidSources.Web,
sourceUrl: doc.link,
description: doc.blurb,
metadata: {
date: doc.updated_at || undefined,
tags: getMetadataTags(doc.metadata),
},
});
/**
* FetchToolRenderer - Renders URL fetch/open tool execution steps
*
* RenderType modes:
* - FULL: Shows all details (URLs being opened + reading results). Header passed as `status` prop.
* Used when step is expanded in timeline.
* - COMPACT: Shows only reading results (no URL list). Header passed as `status` prop.
* Used when step is collapsed in timeline, still wrapped in StepContainer.
* - HIGHLIGHT: Shows URL list with header embedded directly in content.
* No StepContainer wrapper. Used for parallel streaming preview.
*/
export const FetchToolRenderer: MessageRenderer<FetchToolPacket, {}> = ({
packets,
onComplete,
animate,
stopPacketSeen,
renderType,
children,
}) => {
const fetchState = constructCurrentFetchState(packets);
const { urls, documents, hasStarted, isLoading, isComplete } = fetchState;
const isCompact = renderType === RenderType.COMPACT;
const isHighlight = renderType === RenderType.HIGHLIGHT;
if (!hasStarted) {
return children({
icon: FiLink,
status: null,
content: <div />,
supportsCompact: true,
});
}
const displayDocuments = documents.length > 0;
const displayUrls = !displayDocuments && isComplete && urls.length > 0;
// HIGHLIGHT mode: header embedded in content, no StepContainer
if (isHighlight) {
return children({
icon: null,
status: null,
supportsCompact: true,
content: (
<div className="flex flex-col">
<Text as="p" text02 className="text-sm mb-1">
Opening URLs:
</Text>
{displayDocuments ? (
<SearchChipList
items={documents}
initialCount={INITIAL_URLS_TO_SHOW}
expansionCount={URLS_PER_EXPANSION}
getKey={(doc: OnyxDocument) => doc.document_id}
toSourceInfo={(doc: OnyxDocument) => documentToSourceInfo(doc)}
onClick={(doc: OnyxDocument) => {
if (doc.link) window.open(doc.link, "_blank");
}}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
) : displayUrls ? (
<SearchChipList
items={urls}
initialCount={INITIAL_URLS_TO_SHOW}
expansionCount={URLS_PER_EXPANSION}
getKey={(url: string) => url}
toSourceInfo={urlToSourceInfo}
onClick={(url: string) => window.open(url, "_blank")}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
) : (
!stopPacketSeen && <BlinkingDot />
)}
</div>
),
});
}
return children({
icon: FiLink,
status: "Opening URLs:",
supportsCompact: false,
content: (
<div className="flex flex-col">
{!isCompact &&
(displayDocuments ? (
<SearchChipList
items={documents}
initialCount={INITIAL_URLS_TO_SHOW}
expansionCount={URLS_PER_EXPANSION}
getKey={(doc: OnyxDocument) => doc.document_id}
toSourceInfo={(doc: OnyxDocument) => documentToSourceInfo(doc)}
onClick={(doc: OnyxDocument) => {
if (doc.link) window.open(doc.link, "_blank");
}}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
) : displayUrls ? (
<SearchChipList
items={urls}
initialCount={INITIAL_URLS_TO_SHOW}
expansionCount={URLS_PER_EXPANSION}
getKey={(url: string) => url}
toSourceInfo={urlToSourceInfo}
onClick={(url: string) => window.open(url, "_blank")}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
) : (
<div className="flex flex-wrap gap-x-2 gap-y-2 ml-1">
{!stopPacketSeen && <BlinkingDot />}
</div>
))}
{displayUrls && (
<>
{!isCompact && (
<Text as="p" mainUiMuted text03>
Reading results:
</Text>
)}
<SearchChipList
items={urls}
initialCount={INITIAL_URLS_TO_SHOW}
expansionCount={URLS_PER_EXPANSION}
getKey={(url: string, index: number) => `reading-${url}-${index}`}
toSourceInfo={urlToSourceInfo}
onClick={(url: string) => window.open(url, "_blank")}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
</>
)}
</div>
),
});
};

View File

@@ -0,0 +1,48 @@
import {
PacketType,
FetchToolPacket,
FetchToolUrls,
FetchToolDocuments,
} from "@/app/chat/services/streamingModels";
import { OnyxDocument } from "@/lib/search/interfaces";
export const INITIAL_URLS_TO_SHOW = 3;
export const URLS_PER_EXPANSION = 5;
export const READING_MIN_DURATION_MS = 1000;
export const READ_MIN_DURATION_MS = 1000;
export interface FetchState {
urls: string[];
documents: OnyxDocument[];
hasStarted: boolean;
isLoading: boolean;
isComplete: boolean;
}
/** Constructs the current fetch state from fetch tool packets. */
export const constructCurrentFetchState = (
packets: FetchToolPacket[]
): FetchState => {
const startPacket = packets.find(
(packet) => packet.obj.type === PacketType.FETCH_TOOL_START
);
const urlsPacket = packets.find(
(packet) => packet.obj.type === PacketType.FETCH_TOOL_URLS
)?.obj as FetchToolUrls | undefined;
const documentsPacket = packets.find(
(packet) => packet.obj.type === PacketType.FETCH_TOOL_DOCUMENTS
)?.obj as FetchToolDocuments | undefined;
const sectionEnd = packets.find(
(packet) =>
packet.obj.type === PacketType.SECTION_END ||
packet.obj.type === PacketType.ERROR
);
const urls = urlsPacket?.urls || [];
const documents = documentsPacket?.documents || [];
const hasStarted = Boolean(startPacket);
const isLoading = hasStarted && !documentsPacket;
const isComplete = Boolean(startPacket && sectionEnd);
return { urls, documents, hasStarted, isLoading, isComplete };
};

View File

@@ -0,0 +1,10 @@
export { FetchToolRenderer } from "./FetchToolRenderer";
export {
constructCurrentFetchState,
type FetchState,
INITIAL_URLS_TO_SHOW,
URLS_PER_EXPANSION,
READING_MIN_DURATION_MS,
READ_MIN_DURATION_MS,
} from "./fetchStateUtils";

View File

@@ -0,0 +1,140 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
PacketType,
ReasoningDelta,
ReasoningPacket,
} from "@/app/chat/services/streamingModels";
import {
MessageRenderer,
FullChatState,
} from "@/app/chat/message/messageComponents/interfaces";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ExpandableTextDisplay from "@/refresh-components/texts/ExpandableTextDisplay";
import { mutedTextMarkdownComponents } from "@/app/chat/message/messageComponents/timeline/renderers/sharedMarkdownComponents";
import { SvgCircle } from "@opal/icons";
const THINKING_MIN_DURATION_MS = 500; // 0.5 second minimum for "Thinking" state
const THINKING_STATUS = "Thinking";
function constructCurrentReasoningState(packets: ReasoningPacket[]) {
const hasStart = packets.some(
(p) => p.obj.type === PacketType.REASONING_START
);
const hasEnd = packets.some(
(p) =>
p.obj.type === PacketType.SECTION_END ||
p.obj.type === PacketType.ERROR ||
// Support reasoning_done from backend
(p.obj as any).type === PacketType.REASONING_DONE
);
const deltas = packets
.filter((p) => p.obj.type === PacketType.REASONING_DELTA)
.map((p) => p.obj as ReasoningDelta);
const content = deltas.map((d) => d.reasoning).join("");
return {
hasStart,
hasEnd,
content,
};
}
export const ReasoningRenderer: MessageRenderer<
ReasoningPacket,
FullChatState
> = ({ packets, onComplete, animate, children }) => {
const { hasStart, hasEnd, content } = useMemo(
() => constructCurrentReasoningState(packets),
[packets]
);
// Track reasoning timing for minimum display duration
const [reasoningStartTime, setReasoningStartTime] = useState<number | null>(
null
);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const completionHandledRef = useRef(false);
// Track when reasoning starts
useEffect(() => {
if ((hasStart || hasEnd) && reasoningStartTime === null) {
setReasoningStartTime(Date.now());
}
}, [hasStart, hasEnd, reasoningStartTime]);
// Handle reasoning completion with minimum duration
useEffect(() => {
if (
hasEnd &&
reasoningStartTime !== null &&
!completionHandledRef.current
) {
completionHandledRef.current = true;
const elapsedTime = Date.now() - reasoningStartTime;
const minimumThinkingDuration = animate ? THINKING_MIN_DURATION_MS : 0;
if (elapsedTime >= minimumThinkingDuration) {
// Enough time has passed, complete immediately
onComplete();
} else {
// Not enough time has passed, delay completion
const remainingTime = minimumThinkingDuration - elapsedTime;
timeoutRef.current = setTimeout(() => {
onComplete();
}, remainingTime);
}
}
}, [hasEnd, reasoningStartTime, animate, onComplete]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Markdown renderer callback for ExpandableTextDisplay
const renderMarkdown = useCallback(
(text: string) => (
<MinimalMarkdown
content={text}
components={mutedTextMarkdownComponents}
/>
),
[]
);
if (!hasStart && !hasEnd && content.length === 0) {
return children({ icon: SvgCircle, status: null, content: <></> });
}
const reasoningContent = (
<ExpandableTextDisplay
title="Full text"
content={content}
maxLines={5}
renderContent={renderMarkdown}
isStreaming={!hasEnd}
/>
);
return children({
icon: SvgCircle,
status: THINKING_STATUS,
content: reasoningContent,
expandedText: reasoningContent,
});
};
export default ReasoningRenderer;

View File

@@ -0,0 +1,144 @@
import React, { JSX, useState, useEffect, useRef } from "react";
import { SourceTag, SourceInfo } from "@/refresh-components/buttons/source-tag";
import { cn } from "@/lib/utils";
export type { SourceInfo };
const ANIMATION_DELAY_MS = 30;
export interface SearchChipListProps<T> {
items: T[];
initialCount: number;
expansionCount: number;
getKey: (item: T, index: number) => string | number;
toSourceInfo: (item: T, index: number) => SourceInfo;
onClick?: (item: T) => void;
emptyState?: React.ReactNode;
className?: string;
showDetailsCard?: boolean;
}
type DisplayEntry<T> =
| { type: "chip"; item: T; index: number }
| { type: "more"; batchId: number };
export function SearchChipList<T>({
items,
initialCount,
expansionCount,
getKey,
toSourceInfo,
onClick,
emptyState,
className = "",
showDetailsCard,
}: SearchChipListProps<T>): JSX.Element {
const [displayList, setDisplayList] = useState<DisplayEntry<T>[]>([]);
const [batchId, setBatchId] = useState(0);
const animatedKeysRef = useRef<Set<string>>(new Set());
const getEntryKey = (entry: DisplayEntry<T>): string => {
if (entry.type === "more") return `more-button-${entry.batchId}`;
return String(getKey(entry.item, entry.index));
};
useEffect(() => {
const initial: DisplayEntry<T>[] = items
.slice(0, initialCount)
.map((item, i) => ({ type: "chip" as const, item, index: i }));
if (items.length > initialCount) {
initial.push({ type: "more", batchId: 0 });
}
setDisplayList(initial);
setBatchId(0);
}, [items, initialCount]);
const chipCount = displayList.filter((e) => e.type === "chip").length;
const remainingCount = items.length - chipCount;
const remainingItems = items.slice(chipCount);
const handleShowMore = () => {
const nextBatchId = batchId + 1;
setDisplayList((prev) => {
const withoutButton = prev.filter((e) => e.type !== "more");
const currentCount = withoutButton.length;
const newCount = Math.min(currentCount + expansionCount, items.length);
const newItems: DisplayEntry<T>[] = items
.slice(currentCount, newCount)
.map((item, i) => ({
type: "chip" as const,
item,
index: currentCount + i,
}));
const updated = [...withoutButton, ...newItems];
if (newCount < items.length) {
updated.push({ type: "more", batchId: nextBatchId });
}
return updated;
});
setBatchId(nextBatchId);
};
useEffect(() => {
const timer = setTimeout(() => {
displayList.forEach((entry) =>
animatedKeysRef.current.add(getEntryKey(entry))
);
}, 0);
return () => clearTimeout(timer);
}, [displayList]);
let newItemCounter = 0;
return (
<div className={cn("flex flex-wrap gap-x-2 gap-y-2", className)}>
{displayList.map((entry) => {
const key = getEntryKey(entry);
const isNew = !animatedKeysRef.current.has(key);
const delay = isNew ? newItemCounter++ * ANIMATION_DELAY_MS : 0;
return (
<div
key={key}
className={cn("text-xs", {
"animate-in fade-in slide-in-from-left-2 duration-150": isNew,
})}
style={
isNew
? {
animationDelay: `${delay}ms`,
animationFillMode: "backwards",
}
: undefined
}
>
{entry.type === "chip" ? (
<SourceTag
displayName={toSourceInfo(entry.item, entry.index).title}
sources={[toSourceInfo(entry.item, entry.index)]}
onSourceClick={onClick ? () => onClick(entry.item) : undefined}
showDetailsCard={showDetailsCard}
/>
) : (
<SourceTag
displayName={`+${remainingCount} more`}
sources={remainingItems.map((item, i) =>
toSourceInfo(item, chipCount + i)
)}
onSourceClick={() => handleShowMore()}
showDetailsCard={showDetailsCard}
/>
)}
</div>
);
})}
{items.length === 0 && emptyState}
</div>
);
}

View File

@@ -0,0 +1,153 @@
import React from "react";
import { SvgSearch, SvgGlobe, SvgSearchMenu } from "@opal/icons";
import { SearchToolPacket } from "@/app/chat/services/streamingModels";
import {
MessageRenderer,
RenderType,
} from "@/app/chat/message/messageComponents/interfaces";
import { BlinkingDot } from "@/app/chat/message/BlinkingDot";
import { OnyxDocument } from "@/lib/search/interfaces";
import { ValidSources } from "@/lib/types";
import { SearchChipList, SourceInfo } from "./SearchChipList";
import {
constructCurrentSearchState,
INITIAL_QUERIES_TO_SHOW,
QUERIES_PER_EXPANSION,
INITIAL_RESULTS_TO_SHOW,
RESULTS_PER_EXPANSION,
getMetadataTags,
} from "./searchStateUtils";
import Text from "@/refresh-components/texts/Text";
const queryToSourceInfo = (query: string, index: number): SourceInfo => ({
id: `query-${index}`,
title: query,
sourceType: ValidSources.Web,
icon: SvgSearch,
});
const resultToSourceInfo = (doc: OnyxDocument): SourceInfo => ({
id: doc.document_id,
title: doc.semantic_identifier || "",
sourceType: doc.source_type,
sourceUrl: doc.link,
description: doc.blurb,
metadata: {
date: doc.updated_at || undefined,
tags: getMetadataTags(doc.metadata),
},
});
/**
* SearchToolRenderer - Renders search tool execution steps
*
* RenderType modes:
* - FULL: Shows all details (queries list + results). Header passed as `status` prop.
* Used when step is expanded in timeline.
* - COMPACT: Shows only results (no queries). Header passed as `status` prop.
* Used when step is collapsed in timeline, still wrapped in StepContainer.
* - HIGHLIGHT: Shows only results with header embedded directly in content.
* No StepContainer wrapper. Used for parallel streaming preview.
*/
export const SearchToolRenderer: MessageRenderer<SearchToolPacket, {}> = ({
packets,
onComplete,
animate,
stopPacketSeen,
renderType,
children,
}) => {
const searchState = constructCurrentSearchState(packets);
const { queries, results, isSearching, isComplete, isInternetSearch } =
searchState;
const isCompact = renderType === RenderType.COMPACT;
const isHighlight = renderType === RenderType.HIGHLIGHT;
const icon = isInternetSearch ? SvgGlobe : SvgSearchMenu;
const queriesHeader = isInternetSearch
? "Searching the web for:"
: "Searching internal documents for:";
if (queries.length === 0) {
return children({
icon,
status: null,
content: <div />,
supportsCompact: true,
});
}
// HIGHLIGHT mode: header embedded in content, no StepContainer
if (isHighlight) {
return children({
icon: null,
status: null,
supportsCompact: true,
content: (
<div className="flex flex-col">
<Text as="p" text02 className="text-sm mb-1">
{queriesHeader}
</Text>
<SearchChipList
items={results}
initialCount={INITIAL_RESULTS_TO_SHOW}
expansionCount={RESULTS_PER_EXPANSION}
getKey={(doc: OnyxDocument) => doc.document_id}
toSourceInfo={(doc: OnyxDocument) => resultToSourceInfo(doc)}
onClick={(doc: OnyxDocument) => {
if (doc.link) {
window.open(doc.link, "_blank");
}
}}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
</div>
),
});
}
return children({
icon,
status: queriesHeader,
supportsCompact: true,
content: (
<div className="flex flex-col">
{!isCompact && (
<SearchChipList
items={queries}
initialCount={INITIAL_QUERIES_TO_SHOW}
expansionCount={QUERIES_PER_EXPANSION}
getKey={(_, index) => index}
toSourceInfo={queryToSourceInfo}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
showDetailsCard={false}
/>
)}
{(results.length > 0 || queries.length > 0) && (
<>
{!isCompact && (
<Text as="p" mainUiMuted text03>
Reading results:
</Text>
)}
<SearchChipList
items={results}
initialCount={INITIAL_RESULTS_TO_SHOW}
expansionCount={RESULTS_PER_EXPANSION}
getKey={(doc: OnyxDocument) => doc.document_id}
toSourceInfo={(doc: OnyxDocument) => resultToSourceInfo(doc)}
onClick={(doc: OnyxDocument) => {
if (doc.link) {
window.open(doc.link, "_blank");
}
}}
emptyState={!stopPacketSeen ? <BlinkingDot /> : undefined}
/>
</>
)}
</div>
),
});
};

View File

@@ -0,0 +1,18 @@
export { SearchToolRenderer } from "./SearchToolRenderer";
export {
constructCurrentSearchState,
type SearchState,
MAX_TITLE_LENGTH,
INITIAL_QUERIES_TO_SHOW,
QUERIES_PER_EXPANSION,
INITIAL_RESULTS_TO_SHOW,
RESULTS_PER_EXPANSION,
getMetadataTags,
} from "./searchStateUtils";
export {
SearchChipList,
type SearchChipListProps,
type SourceInfo,
} from "./SearchChipList";

View File

@@ -0,0 +1,97 @@
import {
PacketType,
SearchToolPacket,
SearchToolStart,
SearchToolQueriesDelta,
SearchToolDocumentsDelta,
SectionEnd,
} from "@/app/chat/services/streamingModels";
import { OnyxDocument } from "@/lib/search/interfaces";
export const MAX_TITLE_LENGTH = 25;
export const getMetadataTags = (metadata?: {
[key: string]: string;
}): string[] | undefined => {
if (!metadata) return undefined;
const tags = Object.values(metadata)
.filter((value) => typeof value === "string" && value.length > 0)
.slice(0, 2)
.map((value) => `# ${value}`);
return tags.length > 0 ? tags : undefined;
};
export const INITIAL_QUERIES_TO_SHOW = 3;
export const QUERIES_PER_EXPANSION = 5;
export const INITIAL_RESULTS_TO_SHOW = 3;
export const RESULTS_PER_EXPANSION = 10;
export interface SearchState {
queries: string[];
results: OnyxDocument[];
isSearching: boolean;
hasResults: boolean;
isComplete: boolean;
isInternetSearch: boolean;
}
/** Constructs the current search state from search tool packets. */
export const constructCurrentSearchState = (
packets: SearchToolPacket[]
): SearchState => {
const searchStart = packets.find(
(packet) => packet.obj.type === PacketType.SEARCH_TOOL_START
)?.obj as SearchToolStart | null;
const queryDeltas = packets
.filter(
(packet) => packet.obj.type === PacketType.SEARCH_TOOL_QUERIES_DELTA
)
.map((packet) => packet.obj as SearchToolQueriesDelta);
const documentDeltas = packets
.filter(
(packet) => packet.obj.type === PacketType.SEARCH_TOOL_DOCUMENTS_DELTA
)
.map((packet) => packet.obj as SearchToolDocumentsDelta);
const searchEnd = packets.find(
(packet) =>
packet.obj.type === PacketType.SECTION_END ||
packet.obj.type === PacketType.ERROR
)?.obj as SectionEnd | null;
// Deduplicate queries using Set for O(n) instead of indexOf which is O(n²)
const seenQueries = new Set<string>();
const queries = queryDeltas
.flatMap((delta) => delta?.queries || [])
.filter((query) => {
if (seenQueries.has(query)) return false;
seenQueries.add(query);
return true;
});
const seenDocIds = new Set<string>();
const results = documentDeltas
.flatMap((delta) => delta?.documents || [])
.filter((doc) => {
if (!doc || !doc.document_id) return false;
if (seenDocIds.has(doc.document_id)) return false;
seenDocIds.add(doc.document_id);
return true;
});
const isSearching = Boolean(searchStart && !searchEnd);
const hasResults = results.length > 0;
const isComplete = Boolean(searchStart && searchEnd);
const isInternetSearch = searchStart?.is_internet_search || false;
return {
queries,
results,
isSearching,
hasResults,
isComplete,
isInternetSearch,
};
};

View File

@@ -0,0 +1,15 @@
import type { Components } from "react-markdown";
import Text from "@/refresh-components/texts/Text";
export const mutedTextMarkdownComponents = {
p: ({ children }: { children?: React.ReactNode }) => (
<Text as="p" text03 mainUiMuted className="!my-1">
{children}
</Text>
),
li: ({ children }: { children?: React.ReactNode }) => (
<Text as="li" text03 mainUiMuted className="!my-0 !py-0 leading-normal">
{children}
</Text>
),
} satisfies Partial<Components>;

View File

@@ -0,0 +1,109 @@
import { GroupedPacket } from "./hooks/packetProcessor";
/**
* Transformed step data ready for rendering
*/
export interface TransformedStep {
/** Unique key for React rendering */
key: string;
/** Turn index from packet placement */
turnIndex: number;
/** Tab index for parallel tools */
tabIndex: number;
/** Raw packets for content rendering */
packets: GroupedPacket["packets"];
}
/**
* Group steps by turn_index for detecting parallel tools
*/
export interface TurnGroup {
turnIndex: number;
steps: TransformedStep[];
/** True if multiple steps have the same turn_index (parallel execution) */
isParallel: boolean;
}
/**
* Transform a single GroupedPacket into step data
*/
export function transformPacketGroup(group: GroupedPacket): TransformedStep {
return {
key: `${group.turn_index}-${group.tab_index}`,
turnIndex: group.turn_index,
tabIndex: group.tab_index,
packets: group.packets,
};
}
/**
* Transform all packet groups into step data
*/
export function transformPacketGroups(
groups: GroupedPacket[]
): TransformedStep[] {
return groups.map(transformPacketGroup);
}
/**
* Group transformed steps by turn_index to detect parallel tools
*
* @example
* // Input: TransformedStep[]
* // ┌──────────────────────────────────────────┐
* // │ [0] key="0-0" turnIndex=0 tabIndex=0 │
* // │ [1] key="0-1" turnIndex=0 tabIndex=1 │
* // │ [2] key="1-0" turnIndex=1 tabIndex=0 │
* // └──────────────────────────────────────────┘
* //
* // Step 1: Build Map<turnIndex, TransformedStep[]>
* // ┌─────────────────────────────────────────────┐
* // │ turnMap = { │
* // │ 0 → [step(0-0), step(0-1)] │
* // │ 1 → [step(1-0)] │
* // │ } │
* // └─────────────────────────────────────────────┘
* //
* // Step 2: Sort turn indices & steps by tabIndex
* //
* // Step 3: Build TurnGroup[] with isParallel flag
* // ┌─────────────────────────────────────────────┐
* // │ Output: TurnGroup[] │
* // ├─────────────────────────────────────────────┤
* // │ [0] turnIndex=0 │
* // │ steps=[0-0, 0-1] │
* // │ isParallel=true ← 2 steps = parallel │
* // │ │
* // │ [1] turnIndex=1 │
* // │ steps=[1-0] │
* // │ isParallel=false ← 1 step = sequential │
* // └─────────────────────────────────────────────┘
*/
export function groupStepsByTurn(steps: TransformedStep[]): TurnGroup[] {
const turnMap = new Map<number, TransformedStep[]>();
for (const step of steps) {
const existing = turnMap.get(step.turnIndex);
if (existing) {
existing.push(step);
} else {
turnMap.set(step.turnIndex, [step]);
}
}
const result: TurnGroup[] = [];
const sortedTurnIndices = Array.from(turnMap.keys()).sort((a, b) => a - b);
for (const turnIndex of sortedTurnIndices) {
const stepsForTurn = turnMap.get(turnIndex)!;
stepsForTurn.sort((a, b) => a.tabIndex - b.tabIndex);
result.push({
turnIndex,
steps: stepsForTurn,
isParallel: stepsForTurn.length > 1,
});
}
return result;
}

View File

@@ -1,16 +1,5 @@
import { JSX } from "react";
import {
FiCircle,
FiCode,
FiGlobe,
FiImage,
FiLink,
FiList,
FiSearch,
FiTool,
FiUsers,
FiXCircle,
} from "react-icons/fi";
import { FiCircle, FiList, FiTool, FiXCircle } from "react-icons/fi";
import { BrainIcon } from "@/components/icons/icons";
import {
@@ -19,6 +8,15 @@ import {
SearchToolPacket,
} from "@/app/chat/services/streamingModels";
import { constructCurrentSearchState } from "./renderers/SearchToolRenderer";
import {
SvgGlobe,
SvgSearchMenu,
SvgTerminal,
SvgLink,
SvgImage,
SvgUser,
SvgCircle,
} from "@opal/icons";
/**
* Check if a packet group contains an ERROR packet (tool failed)
@@ -119,26 +117,26 @@ export function getToolIcon(packets: Packet[]): JSX.Element {
packets as SearchToolPacket[]
);
return searchState.isInternetSearch ? (
<FiGlobe className="w-3.5 h-3.5" />
<SvgGlobe className="w-3.5 h-3.5" />
) : (
<FiSearch className="w-3.5 h-3.5" />
<SvgSearchMenu className="w-3.5 h-3.5" />
);
}
case PacketType.PYTHON_TOOL_START:
return <FiCode className="w-3.5 h-3.5" />;
return <SvgTerminal className="w-3.5 h-3.5" />;
case PacketType.FETCH_TOOL_START:
return <FiLink className="w-3.5 h-3.5" />;
return <SvgLink className="w-3.5 h-3.5" />;
case PacketType.CUSTOM_TOOL_START:
return <FiTool className="w-3.5 h-3.5" />;
case PacketType.IMAGE_GENERATION_TOOL_START:
return <FiImage className="w-3.5 h-3.5" />;
return <SvgImage className="w-3.5 h-3.5" />;
case PacketType.DEEP_RESEARCH_PLAN_START:
return <FiList className="w-3.5 h-3.5" />;
case PacketType.RESEARCH_AGENT_START:
return <FiUsers className="w-3.5 h-3.5" />;
return <SvgUser className="w-3.5 h-3.5" />;
case PacketType.REASONING_START:
return <BrainIcon className="w-3.5 h-3.5" />;
default:
return <FiCircle className="w-3.5 h-3.5" />;
return <SvgCircle className="w-3.5 h-3.5" />;
}
}

View File

@@ -342,6 +342,7 @@ export function processRawChatHistory(
query: messageInfo.rephrased_query,
documents: messageInfo?.context_docs || [],
citations: messageInfo?.citations || {},
processingDurationSeconds: messageInfo.processing_duration_seconds,
}
: {}),
toolCall: messageInfo.tool_call,

View File

@@ -40,6 +40,9 @@ interface ChatSessionData {
isLoaded: boolean;
description?: string;
personaId?: number;
// Streaming duration tracking
streamingStartTime?: number;
}
interface ChatSessionStore {
@@ -126,6 +129,10 @@ interface ChatSessionStore {
setLoadingError: (sessionId: string, error: string | null) => void;
setIsReady: (sessionId: string, ready: boolean) => void;
// Actions - Streaming Duration
setStreamingStartTime: (sessionId: string, time: number | null) => void;
getStreamingStartTime: (sessionId: string) => number | undefined;
// Actions - Abort Controllers
setAbortController: (sessionId: string, controller: AbortController) => void;
abortSession: (sessionId: string) => void;
@@ -461,6 +468,17 @@ export const useChatSessionStore = create<ChatSessionStore>()((set, get) => ({
get().updateSessionData(sessionId, { isReady });
},
// Streaming Duration Actions
setStreamingStartTime: (sessionId: string, time: number | null) => {
get().updateSessionData(sessionId, {
streamingStartTime: time ?? undefined,
});
},
getStreamingStartTime: (sessionId: string) => {
return get().sessions.get(sessionId)?.streamingStartTime;
},
// Abort Controller Actions
setAbortController: (sessionId: string, controller: AbortController) => {
get().updateSessionData(sessionId, { abortController: controller });
@@ -617,3 +635,12 @@ export const useHasSentLocalUserMessage = () =>
: null;
return currentSession?.hasSentLocalUserMessage || false;
});
export const useStreamingStartTime = () =>
useChatSessionStore((state) => {
const { currentSessionId, sessions } = state;
const currentSession = currentSessionId
? sessions.get(currentSessionId)
: null;
return currentSession?.streamingStartTime;
});

View File

@@ -111,6 +111,11 @@
.prose > :first-child {
margin-top: 0;
}
/* Remove bottom margin from last child to avoid extra space */
.prose > :last-child {
margin-bottom: 0;
}
}
@layer utilities {

View File

@@ -1,35 +0,0 @@
import React from "react";
import { cn } from "@/lib/utils";
interface FadeDivProps {
className?: string;
fadeClassName?: string;
footerClassName?: string;
children: React.ReactNode;
}
const FadeDiv: React.FC<FadeDivProps> = ({
className,
fadeClassName,
footerClassName,
children,
}) => (
<div className={cn("relative w-full", className)}>
<div
className={cn(
"absolute inset-x-0 -top-8 h-8 bg-gradient-to-b from-transparent to-background pointer-events-none",
fadeClassName
)}
/>
<div
className={cn(
"flex items-center justify-end w-full pt-2 px-2",
footerClassName
)}
>
{children}
</div>
</div>
);
export default FadeDiv;

View File

@@ -7,7 +7,6 @@ import HumanMessage from "@/app/chat/message/HumanMessage";
import { ErrorBanner } from "@/app/chat/message/Resubmit";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import AIMessage from "@/app/chat/message/messageComponents/AIMessage";
import Spacer from "@/refresh-components/Spacer";
import {
useCurrentMessageHistory,
@@ -15,6 +14,7 @@ import {
useLoadingError,
useUncaughtError,
} from "@/app/chat/stores/useChatSessionStore";
import AgentMessage from "@/app/chat/message/messageComponents/AgentMessage";
export interface MessageListProps {
liveAssistant: MinimalPersonaSnapshot;
@@ -181,8 +181,10 @@ const MessageList = React.memo(
key={messageReactComponentKey}
data-anchor={isAnchor ? "true" : undefined}
>
<AIMessage
<AgentMessage
processingDurationSeconds={message.processingDurationSeconds}
rawPackets={message.packets}
packetCount={message.packetCount}
chatState={chatStateData}
nodeId={message.nodeId}
messageId={message.messageId}

View File

@@ -142,3 +142,12 @@ export function getSecondsUntilExpiration(
)
);
}
export function formatDurationSeconds(seconds: number): string {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}

View File

@@ -169,3 +169,21 @@ export function hasNonImageFiles(
): boolean {
return files.some((file) => !isImageFile(file.name));
}
/**
* Merges multiple refs into a single callback ref.
* Useful when a component needs both an internal ref and a forwarded ref.
*/
export function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<T | null>).current = node;
}
});
};
}

View File

@@ -0,0 +1,64 @@
import React from "react";
import { cn } from "@/lib/utils";
interface FadingEdgeContainerProps {
/** Classes applied to the inner scrollable container */
className?: string;
/** Classes to customize the fade gradient (e.g., height, color) */
fadeClassName?: string;
children: React.ReactNode;
/** Which edge to show the fade on */
direction?: "top" | "bottom";
}
/**
* A container that adds a gradient fade overlay at the top or bottom edge.
*
* Use this component to wrap scrollable content where you want to visually
* indicate that more content exists beyond the visible area. The fade stays
* fixed relative to the container bounds, not the scroll content.
*
* @example
* // Bottom fade for a scrollable list
* <FadingEdgeContainer
* direction="bottom"
* className="max-h-[300px] overflow-y-auto"
* >
* {items.map(item => <Item key={item.id} />)}
* </FadingEdgeContainer>
*
* @example
* // Top fade with custom fade styling
* <FadingEdgeContainer
* direction="top"
* className="max-h-[200px] overflow-y-auto"
* fadeClassName="h-12"
* >
* {content}
* </FadingEdgeContainer>
*/
const FadingEdgeContainer: React.FC<FadingEdgeContainerProps> = ({
className,
fadeClassName,
children,
direction = "top",
}) => {
const isTop = direction === "top";
return (
<div className="relative">
<div className={className}>{children}</div>
<div
className={cn(
"absolute inset-x-0 h-8 pointer-events-none z-10",
isTop
? "top-0 bg-gradient-to-b from-background to-transparent"
: "bottom-0 bg-gradient-to-t from-background to-transparent",
fadeClassName
)}
/>
</div>
);
};
export default FadingEdgeContainer;

View File

@@ -75,6 +75,7 @@ const useModalContext = () => {
const widthClasses = {
lg: "w-[80dvw]",
md: "w-[60rem]",
"md-sm": "w-[40rem]",
sm: "w-[32rem]",
};

View File

@@ -1,45 +1,337 @@
"use client";
import React from "react";
import React, {
useRef,
useState,
useEffect,
useMemo,
useCallback,
} from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
import { cn, mergeRefs } from "@/lib/utils";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { WithoutStyles } from "@/types";
import { Section, SectionProps } from "@/layouts/general-layouts";
import { IconProps } from "@opal/types";
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
import Text from "./texts/Text";
import IconButton from "./buttons/IconButton";
/* =============================================================================
CONTEXT
============================================================================= */
interface TabsContextValue {
variant: "contained" | "pill";
}
const TabsContext = React.createContext<TabsContextValue | undefined>(
undefined
);
const useTabsContext = () => {
const context = React.useContext(TabsContext);
return context; // Returns undefined if used outside Tabs.List (allows explicit override)
};
/**
* TABS COMPONENT VARIANTS
*
* Contained (default):
* ┌─────────────────────────────────────────────────┐
* │ ┌──────────┐ ╔══════════╗ ┌──────────┐ │
* │ │ Tab 1 │ ║ Tab 2 ║ │ Tab 3 │ │ ← gray background
* │ └──────────┘ ╚══════════╝ └──────────┘ │
* └─────────────────────────────────────────────────┘
* ↑ active tab (white bg, shadow)
*
* Pill:
* Tab 1 Tab 2 Tab 3 [Action]
* ╔═════╗
* ║ ║ ↑ optional rightContent
* ────────────╨═════╨─────────────────────────────
* ↑ sliding indicator under active tab
*
* @example
* <Tabs defaultValue="tab1">
* <Tabs.List variant="pill">
* <Tabs.Trigger value="tab1">Overview</Tabs.Trigger>
* <Tabs.Trigger value="tab2">Details</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Overview content</Tabs.Content>
* <Tabs.Content value="tab2">Details content</Tabs.Content>
* </Tabs>
*/
/* =============================================================================
VARIANT STYLES
Centralized styling definitions for tabs variants.
============================================================================= */
/** Style classes for TabsList variants */
const listVariants = {
contained: "grid w-full rounded-08 bg-background-tint-03",
pill: "relative flex items-center pb-[4px] bg-background-tint-00 px-1 overflow-hidden min-w-0",
} as const;
/** Base style classes for TabsTrigger variants */
const triggerBaseStyles = {
contained: "p-2 gap-2",
pill: "p-1.5 font-secondary-action transition-all duration-200 ease-out",
} as const;
/** Icon style classes for TabsTrigger variants */
const iconVariants = {
contained: "stroke-text-03",
pill: "stroke-current",
} as const;
/* =============================================================================
CONSTANTS
============================================================================= */
/** Pixel tolerance for detecting scroll boundaries (accounts for rounding) */
const SCROLL_TOLERANCE_PX = 1;
/** Pixel amount to scroll when clicking scroll arrows */
const SCROLL_AMOUNT_PX = 200;
/* =============================================================================
HOOKS
============================================================================= */
/** Style properties for the pill indicator position */
interface IndicatorStyle {
left: number;
width: number;
opacity: number;
}
/**
* Hook to track and animate a sliding indicator under the active tab.
*
* Uses MutationObserver to detect when the active tab changes (via data-state
* attribute updates from Radix UI) and calculates the indicator position.
*
* @param listRef - Ref to the TabsList container element
* @param enabled - Whether indicator tracking is enabled (only true for pill variant)
* @returns Style object with left, width, and opacity for the indicator element
*/
function usePillIndicator(
listRef: React.RefObject<HTMLElement | null>,
enabled: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>
): { style: IndicatorStyle; isScrolling: boolean } {
const [style, setStyle] = useState<IndicatorStyle>({
left: 0,
width: 0,
opacity: 0,
});
const [isScrolling, setIsScrolling] = useState(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enabled) return;
const list = listRef.current;
if (!list) return;
const updateIndicator = () => {
const activeTab = list.querySelector<HTMLElement>(
'[data-state="active"]'
);
if (activeTab) {
const listRect = list.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
setStyle({
left: tabRect.left - listRect.left,
width: tabRect.width,
opacity: 1,
});
}
};
const handleScroll = () => {
setIsScrolling(true);
updateIndicator();
// Clear existing timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Reset scrolling state after scroll ends
scrollTimeoutRef.current = setTimeout(() => {
setIsScrolling(false);
}, 150);
};
updateIndicator();
// Watch for size changes on ANY tab (sibling size changes affect active tab position)
const resizeObserver = new ResizeObserver(() => updateIndicator());
list.querySelectorAll<HTMLElement>('[role="tab"]').forEach((tab) => {
resizeObserver.observe(tab);
});
// Watch for data-state changes (tab switches)
const mutationObserver = new MutationObserver(() => updateIndicator());
mutationObserver.observe(list, {
attributes: true,
subtree: true,
attributeFilter: ["data-state"],
});
// Listen for scroll events on scroll container
const scrollContainer = scrollContainerRef?.current;
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
}
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [enabled, listRef, scrollContainerRef]);
return { style, isScrolling };
}
/** State for horizontal scroll arrows */
interface ScrollState {
canScrollLeft: boolean;
canScrollRight: boolean;
scrollLeft: () => void;
scrollRight: () => void;
}
/**
* Hook to manage horizontal scrolling with arrow navigation.
*
* Tracks scroll position and overflow state of a container, providing
* scroll functions and boolean flags for arrow visibility.
*
* @param containerRef - Ref to the scrollable container element
* @param enabled - Whether scroll tracking is enabled
* @returns Object with canScrollLeft, canScrollRight, and scroll functions
*/
function useHorizontalScroll(
containerRef: React.RefObject<HTMLElement | null>,
enabled: boolean
): ScrollState {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const { scrollLeft, scrollWidth, clientWidth } = container;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(
scrollLeft + clientWidth < scrollWidth - SCROLL_TOLERANCE_PX
);
}, [containerRef]);
useEffect(() => {
if (!enabled) return;
const container = containerRef.current;
if (!container) return;
// Delay initial measurement until after layout
const rafId = requestAnimationFrame(() => {
updateScrollState();
});
container.addEventListener("scroll", updateScrollState);
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
// Also observe children for size changes
Array.from(container.children).forEach((child) => {
resizeObserver.observe(child);
});
return () => {
cancelAnimationFrame(rafId);
container.removeEventListener("scroll", updateScrollState);
resizeObserver.disconnect();
};
}, [enabled, containerRef, updateScrollState]);
const scrollLeft = useCallback(() => {
containerRef.current?.scrollBy({
left: -SCROLL_AMOUNT_PX,
behavior: "smooth",
});
}, [containerRef]);
const scrollRight = useCallback(() => {
containerRef.current?.scrollBy({
left: SCROLL_AMOUNT_PX,
behavior: "smooth",
});
}, [containerRef]);
return { canScrollLeft, canScrollRight, scrollLeft, scrollRight };
}
/* =============================================================================
SUB-COMPONENTS
============================================================================= */
/**
* Renders the bottom line and sliding indicator for the pill variant.
* The indicator animates smoothly when switching between tabs.
*
* @param style - Position and opacity for the sliding indicator
* @param rightOffset - Distance from the right edge where the border line should stop (for rightContent)
*/
function PillIndicator({
style,
rightOffset = 0,
}: {
style: IndicatorStyle;
rightOffset?: number;
}) {
return (
<>
<div
className="absolute bottom-0 left-0 h-px bg-border-02 pointer-events-none"
style={{ right: rightOffset }}
/>
<div
className="absolute bottom-0 h-[2px] bg-background-tint-inverted-03 z-10 pointer-events-none transition-all duration-200 ease-out"
style={{
left: style.left,
width: style.width,
opacity: style.opacity,
}}
/>
</>
);
}
/* =============================================================================
MAIN COMPONENTS
============================================================================= */
/**
* Tabs Root Component
*
* Container for tab navigation and content. Manages the active tab state.
* Supports both controlled and uncontrolled modes.
*
* @param defaultValue - The tab value that should be active by default (uncontrolled)
* @param defaultValue - The tab value that should be active by default (uncontrolled mode)
* @param value - The controlled active tab value
* @param onValueChange - Callback when the active tab changes
*
* @example
* ```tsx
* // Uncontrolled tabs (state managed internally)
* <Tabs defaultValue="account">
* <Tabs.List>
* <Tabs.Trigger value="account">Account</Tabs.Trigger>
* <Tabs.Trigger value="password">Password</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="account">Account settings content</Tabs.Content>
* <Tabs.Content value="password">Password settings content</Tabs.Content>
* </Tabs>
*
* // Controlled tabs (explicit state management)
* <Tabs value={activeTab} onValueChange={setActiveTab}>
* <Tabs.List>
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Content 1</Tabs.Content>
* <Tabs.Content value="tab2">Content 2</Tabs.Content>
* </Tabs>
* ```
* @param onValueChange - Callback fired when the active tab changes
*/
const TabsRoot = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
@@ -49,44 +341,192 @@ const TabsRoot = React.forwardRef<
));
TabsRoot.displayName = TabsPrimitive.Root.displayName;
/* -------------------------------------------------------------------------- */
/**
* Tabs List Props
*/
interface TabsListProps
extends Omit<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
"style"
> {
/**
* Visual variant of the tabs list.
*
* - `contained` (default): Rounded background with equal-width tabs in a grid.
* Best for primary navigation where tabs should fill available space.
*
* - `pill`: Transparent background with a sliding underline indicator.
* Best for secondary navigation or filter-style tabs with flexible widths.
*/
variant?: "contained" | "pill";
/**
* Content to render on the right side of the tab list.
* Only applies to the `pill` variant (ignored for `contained`).
*
* @example
* ```tsx
* <Tabs.List variant="pill" rightContent={<Button size="sm">Add New</Button>}>
* <Tabs.Trigger value="all">All</Tabs.Trigger>
* <Tabs.Trigger value="active">Active</Tabs.Trigger>
* </Tabs.List>
* ```
*/
rightContent?: React.ReactNode;
/**
* Enable horizontal scroll arrows when tabs overflow.
* Only applies to the `pill` variant.
* @default false
*/
enableScrollArrows?: boolean;
}
/**
* Tabs List Component
*
* Container for tab triggers. Renders as a horizontal list with pill-style background.
* Automatically manages keyboard navigation (arrow keys) and accessibility attributes.
*
* @example
* ```tsx
* <Tabs defaultValue="overview">
* <Tabs.List>
* <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
* <Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
* <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="overview">...</Tabs.Content>
* <Tabs.Content value="analytics">...</Tabs.Content>
* <Tabs.Content value="settings">...</Tabs.Content>
* </Tabs>
* ```
* Container for tab triggers. Renders as a horizontal list with automatic
* keyboard navigation (arrow keys, Home/End) and accessibility attributes.
*
* @remarks
* - Default styling: rounded pill background with padding
* - Height: 2.5rem (h-10)
* - Supports keyboard navigation (Left/Right arrows, Home/End keys)
* - Custom className can be added for additional styling if needed
* - **Contained**: Uses CSS Grid for equal-width tabs with rounded background
* - **Pill**: Uses Flexbox for content-width tabs with animated bottom indicator
* - The `variant` prop is automatically propagated to child `Tabs.Trigger` components via context
*/
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
WithoutStyles<React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>>
>((props, ref) => (
<TabsPrimitive.List
ref={ref}
className="flex w-full rounded-08 bg-background-tint-03"
{...props}
/>
));
TabsListProps
>(
(
{
variant = "contained",
rightContent,
enableScrollArrows = false,
children,
className,
...props
},
ref
) => {
const listRef = useRef<HTMLDivElement>(null);
const tabsContainerRef = useRef<HTMLDivElement>(null);
const rightContentRef = useRef<HTMLDivElement>(null);
const [rightContentWidth, setRightContentWidth] = useState(0);
const isPill = variant === "pill";
const { style: indicatorStyle } = usePillIndicator(
listRef,
isPill,
enableScrollArrows ? tabsContainerRef : undefined
);
const contextValue = useMemo(() => ({ variant }), [variant]);
const {
canScrollLeft,
canScrollRight,
scrollLeft: handleScrollLeft,
scrollRight: handleScrollRight,
} = useHorizontalScroll(tabsContainerRef, isPill && enableScrollArrows);
const showScrollArrows =
isPill && enableScrollArrows && (canScrollLeft || canScrollRight);
// Track right content width to offset the border line
useEffect(() => {
if (!isPill || !rightContent) {
setRightContentWidth(0);
return;
}
const rightEl = rightContentRef.current;
if (!rightEl) return;
const updateWidth = () => {
setRightContentWidth(rightEl.offsetWidth);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(rightEl);
return () => resizeObserver.disconnect();
}, [isPill, rightContent]);
return (
<TabsPrimitive.List
ref={mergeRefs(listRef, ref)}
className={cn(listVariants[variant], className)}
style={
variant === "contained"
? {
gridTemplateColumns: `repeat(${React.Children.count(
children
)}, 1fr)`,
}
: undefined
}
{...props}
>
<TabsContext.Provider value={contextValue}>
{isPill ? (
enableScrollArrows ? (
<div
ref={tabsContainerRef}
className="flex items-center gap-2 pt-1 overflow-x-auto scrollbar-hide flex-1 min-w-0"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{children}
</div>
) : (
<div className="flex items-center gap-2 pt-1">{children}</div>
)
) : (
children
)}
{showScrollArrows && (
<div className="flex items-center gap-1 pl-2 flex-shrink-0">
<IconButton
main
internal
icon={SvgChevronLeft}
onClick={handleScrollLeft}
disabled={!canScrollLeft}
tooltip="Scroll tabs left"
/>
<IconButton
main
internal
icon={SvgChevronRight}
onClick={handleScrollRight}
disabled={!canScrollRight}
tooltip="Scroll tabs right"
/>
</div>
)}
{isPill && rightContent && (
<div ref={rightContentRef} className="ml-auto pl-2 flex-shrink-0">
{rightContent}
</div>
)}
{isPill && (
<PillIndicator
style={indicatorStyle}
rightOffset={rightContentWidth}
/>
)}
</TabsContext.Provider>
</TabsPrimitive.List>
);
}
);
TabsList.displayName = TabsPrimitive.List.displayName;
/* -------------------------------------------------------------------------- */
/**
* Tabs Trigger Props
*/
@@ -97,72 +537,74 @@ interface TabsTriggerProps
"children"
>
> {
/**
* Visual variant of the tab trigger.
* Automatically inherited from the parent `Tabs.List` variant via context.
* Can be explicitly set to override the inherited value.
*
* - `contained` (default): White background with shadow when active
* - `pill`: Dark pill background when active, transparent when inactive
*/
variant?: "contained" | "pill";
/** Optional tooltip text to display on hover */
tooltip?: string;
/** Side where tooltip appears. Default: "top" */
/** Side where tooltip appears. @default "top" */
tooltipSide?: "top" | "bottom" | "left" | "right";
/** Optional icon component to render before the label */
icon?: React.FunctionComponent<IconProps>;
children?: string;
/** Tab label - can be string or ReactNode for custom content */
children?: React.ReactNode;
/** Show loading spinner after label */
isLoading?: boolean;
}
/**
* Tabs Trigger Component
*
* Individual tab button that switches the active tab when clicked.
* Supports tooltips and disabled state with special tooltip handling.
*
* @param value - Unique value identifying this tab (required)
* @param tooltip - Optional tooltip text shown on hover
* @param tooltipSide - Side where tooltip appears (top, bottom, left, right). Default: "top"
* @param disabled - Whether the tab is disabled
*
* @example
* ```tsx
* // Basic tabs
* <Tabs.List>
* <Tabs.Trigger value="home">Home</Tabs.Trigger>
* <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
* </Tabs.List>
*
* // With tooltips
* <Tabs.List>
* <Tabs.Trigger value="edit" tooltip="Edit document">
* <SvgEdit />
* </Tabs.Trigger>
* <Tabs.Trigger value="share" tooltip="Share with others" tooltipSide="bottom">
* <SvgShare />
* </Tabs.Trigger>
* </Tabs.List>
*
* // With disabled state and tooltip
* <Tabs.List>
* <Tabs.Trigger value="admin" disabled tooltip="Admin access required">
* Admin Panel
* </Tabs.Trigger>
* </Tabs.List>
* ```
* Supports icons, tooltips, loading states, and disabled state.
*
* @remarks
* - Active state: white background with shadow
* - Inactive state: transparent with hover effect
* - Disabled state: reduced opacity, no pointer events
* - Tooltips work on both enabled and disabled triggers
* - Disabled triggers require special tooltip wrapping to show tooltips
* - Automatic focus management and keyboard navigation
* - **Contained active**: White background with subtle shadow
* - **Pill active**: Dark inverted background
* - Tooltips work on disabled triggers via wrapper span technique
* - Loading spinner appears after the label text
*/
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
TabsTriggerProps
>(
(
{ tooltip, tooltipSide = "top", icon: Icon, children, disabled, ...props },
{
variant: variantProp,
tooltip,
tooltipSide = "top",
icon: Icon,
children,
disabled,
isLoading,
...props
},
ref
) => {
const context = useTabsContext();
const variant = variantProp ?? context?.variant ?? "contained";
const inner = (
<>
{Icon && <Icon size={16} className="stroke-text-03" />}
<Text>{children}</Text>
{Icon && <Icon size={14} className={cn(iconVariants[variant])} />}
{typeof children === "string" ? <Text>{children}</Text> : children}
{isLoading && (
<span
className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin ml-1"
aria-label="Loading"
/>
)}
</>
);
@@ -171,17 +613,37 @@ const TabsTrigger = React.forwardRef<
ref={ref}
disabled={disabled}
className={cn(
"flex-1 inline-flex items-center justify-center whitespace-nowrap rounded-08 p-2 gap-2",
// active/inactive states:
"data-[state=active]:bg-background-neutral-00 data-[state=active]:text-text-04 data-[state=active]:shadow-01 data-[state=active]:border",
"data-[state=inactive]:text-text-03 data-[state=inactive]:bg-transparent data-[state=inactive]:border data-[state=inactive]:border-transparent"
"inline-flex items-center justify-center whitespace-nowrap rounded-08",
triggerBaseStyles[variant],
variant === "contained" && [
"data-[state=active]:bg-background-neutral-00",
"data-[state=active]:text-text-04",
"data-[state=active]:shadow-01",
"data-[state=active]:border",
"data-[state=active]:border-border-01",
],
variant === "pill" && [
"data-[state=active]:bg-background-tint-inverted-03",
"data-[state=active]:text-text-inverted-05",
],
variant === "contained" && [
"data-[state=inactive]:text-text-03",
"data-[state=inactive]:bg-transparent",
"data-[state=inactive]:border",
"data-[state=inactive]:border-transparent",
],
variant === "pill" && [
"data-[state=inactive]:bg-background-tint-00",
"data-[state=inactive]:text-text-03",
]
)}
{...props}
>
{tooltip && !disabled ? (
<SimpleTooltip tooltip={tooltip} side={tooltipSide}>
{inner}
<span className="inline-flex items-center gap-inherit">
{inner}
</span>
</SimpleTooltip>
) : (
inner
@@ -189,9 +651,9 @@ const TabsTrigger = React.forwardRef<
</TabsPrimitive.Trigger>
);
// Disabled native buttons don't emit pointer/focus events, so tooltips inside
// them won't trigger. Wrap the *entire* trigger with a neutral span only when
// disabled so layout stays unchanged for the enabled case.
// Disabled native buttons don't emit pointer/focus events, so tooltips
// inside them won't trigger. Wrap the entire trigger with a neutral span
// only when disabled so layout stays unchanged for the enabled case.
if (tooltip && disabled) {
return (
<SimpleTooltip tooltip={tooltip} side={tooltipSide}>
@@ -207,6 +669,8 @@ const TabsTrigger = React.forwardRef<
);
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
/* -------------------------------------------------------------------------- */
/**
* Tabs Content Component
*
@@ -214,34 +678,6 @@ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
* Only the content for the active tab is rendered and visible.
*
* @param value - The tab value this content is associated with (must match a Tabs.Trigger value)
*
* @example
* ```tsx
* <Tabs defaultValue="details">
* <Tabs.List>
* <Tabs.Trigger value="details">Details</Tabs.Trigger>
* <Tabs.Trigger value="logs">Logs</Tabs.Trigger>
* </Tabs.List>
*
* <Tabs.Content value="details">
* <Section>
* <Text>Detailed information goes here</Text>
* </Section>
* </Tabs.Content>
*
* <Tabs.Content value="logs">
* <Section>
* <LogViewer logs={logs} />
* </Section>
* </Tabs.Content>
* </Tabs>
* ```
*
* @remarks
* - Content is only mounted/visible when its associated tab is active
* - Default top margin of 0.5rem (mt-2) to separate from tabs
* - Supports focus management for accessibility
* - Custom className can override default styling
*/
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -259,6 +695,10 @@ const TabsContent = React.forwardRef<
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
/* =============================================================================
EXPORTS
============================================================================= */
export default Object.assign(TabsRoot, {
List: TabsList,
Trigger: TabsTrigger,

View File

@@ -0,0 +1,236 @@
"use client";
import { memo, useState, useMemo, useCallback } from "react";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SourceIcon } from "@/components/SourceIcon";
import { WebResultIcon } from "@/components/WebResultIcon";
import { ValidSources } from "@/lib/types";
import SourceTagDetailsCard, {
SourceInfo,
} from "@/refresh-components/buttons/source-tag/SourceTagDetailsCard";
export type { SourceInfo };
// Variant-specific styles
const sizeClasses = {
inlineCitation: {
container: "rounded-04 p-0.5 gap-0.5",
},
tag: {
container: "rounded-08 p-1 gap-1",
},
} as const;
const getIconKey = (source: SourceInfo): string => {
if (source.icon) return source.icon.name || "custom";
if (source.sourceType === ValidSources.Web && source.sourceUrl) {
try {
return new URL(source.sourceUrl).hostname;
} catch {
return source.sourceUrl;
}
}
return source.sourceType;
};
export interface SourceTagProps {
/** Use inline citation size (smaller, for use within text) */
inlineCitation?: boolean;
/** Display name shown on the tag (e.g., "Google Drive", "Business Insider") */
displayName: string;
/** URL to display below name (for site type - shows domain) */
displayUrl?: string;
/** Array of sources for navigation in details card */
sources: SourceInfo[];
/** Callback when a source is clicked in the details card */
onSourceClick?: () => void;
/** Whether to show the details card on hover (defaults to true) */
showDetailsCard?: boolean;
/** Additional CSS classes */
className?: string;
}
const SourceTagInner = ({
inlineCitation,
displayName,
displayUrl,
sources,
onSourceClick,
showDetailsCard = true,
className,
}: SourceTagProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const uniqueSources = useMemo(
() =>
sources.filter(
(source, index, arr) =>
arr.findIndex((s) => getIconKey(s) === getIconKey(source)) === index
),
[sources]
);
const showCount = sources.length > 1;
const extraCount = sources.length - 1;
const size = inlineCitation ? "inlineCitation" : "tag";
const styles = sizeClasses[size];
const handlePrev = useCallback(() => {
setCurrentIndex((prev) => Math.max(0, prev - 1));
}, []);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => Math.min(sources.length - 1, prev + 1));
}, [sources.length]);
// Reset to first source when tooltip closes
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
if (!open) {
setCurrentIndex(0);
}
}, []);
const buttonContent = (
<button
type="button"
className={cn(
"group inline-flex items-center cursor-pointer transition-all duration-150",
"appearance-none border-none bg-background-tint-02",
isOpen && "bg-background-tint-inverted-03",
!showDetailsCard && "hover:bg-background-tint-inverted-03",
styles.container,
className
)}
onClick={() => onSourceClick?.()}
>
{/* Stacked icons container - only for tag variant */}
{!inlineCitation && (
<div className="flex items-center -space-x-1.5">
{uniqueSources.slice(0, 3).map((source, index) => (
<div
key={source.id}
className={cn(
"relative flex items-center justify-center p-0.5 rounded-04",
"bg-background-tint-00 border transition-colors duration-150",
isOpen
? "border-background-tint-inverted-03"
: "border-background-tint-02",
!showDetailsCard &&
"group-hover:border-background-tint-inverted-03"
)}
style={{ zIndex: uniqueSources.slice(0, 3).length - index }}
>
{source.icon ? (
<source.icon size={12} />
) : source.sourceType === ValidSources.Web && source.sourceUrl ? (
<WebResultIcon url={source.sourceUrl} size={12} />
) : (
<SourceIcon
sourceType={
source.sourceType === ValidSources.Web
? ValidSources.Web
: source.sourceType
}
iconSize={12}
/>
)}
</div>
))}
</div>
)}
<div className={cn("flex items-baseline", !inlineCitation && "pr-0.5")}>
<Text
figureSmallValue={inlineCitation}
secondaryBody={!inlineCitation}
text05={isOpen}
text03={!isOpen && inlineCitation}
text04={!isOpen && !inlineCitation}
inverted={isOpen}
className={cn(
"max-w-[10rem] truncate transition-colors duration-150",
!showDetailsCard && "group-hover:text-text-inverted-05"
)}
>
{displayName}
</Text>
{/* Count - for inline citation */}
{inlineCitation && showCount && (
<Text
figureSmallValue
text05={isOpen}
text03={!isOpen}
inverted={isOpen}
className={cn(
"transition-colors duration-150",
!showDetailsCard && "group-hover:text-text-inverted-05"
)}
>
+{extraCount}
</Text>
)}
{/* URL - for tag variant */}
{!inlineCitation && displayUrl && (
<Text
figureSmallValue
text05={isOpen}
text02={!isOpen}
inverted={isOpen}
className={cn(
"max-w-[10rem] truncate transition-colors duration-150",
!showDetailsCard && "group-hover:text-text-inverted-05"
)}
>
{displayUrl}
</Text>
)}
</div>
</button>
);
if (!showDetailsCard) {
return buttonContent;
}
return (
<TooltipProvider delayDuration={50}>
<Tooltip open={isOpen} onOpenChange={handleOpenChange}>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
sideOffset={4}
className="bg-transparent p-0 shadow-none border-none"
>
<SourceTagDetailsCard
sources={sources}
currentIndex={currentIndex}
onPrev={handlePrev}
onNext={handleNext}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const SourceTag = memo(SourceTagInner);
export default SourceTag;

View File

@@ -0,0 +1,184 @@
"use client";
import React, { memo } from "react";
import Text from "@/refresh-components/texts/Text";
import IconButton from "@/refresh-components/buttons/IconButton";
import {
SvgArrowLeft,
SvgArrowRight,
SvgUser,
SvgQuestionMarkSmall,
} from "@opal/icons";
import { SourceIcon } from "@/components/SourceIcon";
import { WebResultIcon } from "@/components/WebResultIcon";
import { ValidSources } from "@/lib/types";
import { timeAgo } from "@/lib/time";
import { IconProps } from "@/components/icons/icons";
import { SubQuestionDetail } from "@/app/chat/interfaces";
export interface SourceInfo {
id: string;
title: string;
sourceType: ValidSources;
sourceUrl?: string;
description?: string;
metadata?: {
author?: string;
date?: string | Date;
tags?: string[];
};
icon?: React.FunctionComponent<IconProps>;
// Support for questions
isQuestion?: boolean;
questionData?: SubQuestionDetail;
}
interface SourceTagDetailsCardProps {
sources: SourceInfo[];
currentIndex: number;
onPrev: () => void;
onNext: () => void;
}
interface MetadataChipProps {
icon?: React.FunctionComponent<IconProps>;
text: string;
}
const MetadataChip = memo(function MetadataChip({
icon: Icon,
text,
}: MetadataChipProps) {
return (
<div className="flex items-center gap-0 bg-background-tint-02 rounded-08 p-1">
{Icon && (
<div className="flex items-center justify-center p-0.5 w-4 h-4">
<Icon className="w-3 h-3 stroke-text-03" />
</div>
)}
<Text secondaryBody text03 className="px-0.5 max-w-[10rem] truncate">
{text}
</Text>
</div>
);
});
const SourceTagDetailsCardInner = ({
sources,
currentIndex,
onPrev,
onNext,
}: SourceTagDetailsCardProps) => {
const currentSource = sources[currentIndex];
if (!currentSource) return null;
const showNavigation = sources.length > 1;
const isFirst = currentIndex === 0;
const isLast = currentIndex === sources.length - 1;
const isWebSource = currentSource.sourceType === "web";
const isQuestion = currentSource.isQuestion;
const relativeDate = timeAgo(
currentSource.metadata?.date instanceof Date
? currentSource.metadata.date.toISOString()
: currentSource.metadata?.date
);
return (
<div className="w-[17.5rem] bg-background-neutral-00 border border-border-01 rounded-12 shadow-01 overflow-hidden">
{/* Navigation header - only shown for multiple sources */}
{showNavigation && (
<div className="flex items-center justify-between p-2 bg-background-tint-01 border-b border-border-01">
<div className="flex items-center gap-1">
<IconButton
main
internal
icon={SvgArrowLeft}
onClick={onPrev}
disabled={isFirst}
className="!p-0.5"
/>
<IconButton
main
internal
icon={SvgArrowRight}
onClick={onNext}
disabled={isLast}
className="!p-0.5"
/>
</div>
<Text secondaryBody text03 className="px-1">
{currentIndex + 1}/{sources.length}
</Text>
</div>
)}
<div className="p-1 flex flex-col gap-1">
{/* Header with icon and title */}
<div className="flex items-start gap-1 p-0.5 min-h-[1.75rem] w-full text-left hover:bg-background-tint-01 rounded-08 transition-colors">
<div className="flex items-center justify-center p-0.5 shrink-0 w-5 h-5">
{isQuestion ? (
<SvgQuestionMarkSmall size={16} className="text-text-03" />
) : currentSource.icon ? (
<currentSource.icon size={16} />
) : isWebSource && currentSource.sourceUrl ? (
<WebResultIcon url={currentSource.sourceUrl} size={16} />
) : (
<SourceIcon
sourceType={
currentSource.sourceType === "web"
? ValidSources.Web
: currentSource.sourceType
}
iconSize={16}
/>
)}
</div>
<div className="flex-1 min-w-0 px-0.5">
<Text
mainUiAction
text04
className="truncate w-full block leading-5"
>
{currentSource.title}
</Text>
</div>
</div>
{/* Metadata row */}
{(currentSource.metadata?.author ||
currentSource.metadata?.tags?.length ||
relativeDate) && (
<div className="flex flex-row items-center gap-2 ">
<div className="flex flex-wrap gap-1 items-center">
{currentSource.metadata?.author && (
<MetadataChip
icon={SvgUser}
text={currentSource.metadata.author}
/>
)}
{currentSource.metadata?.tags
?.slice(0, 2)
.map((tag) => <MetadataChip key={tag} text={tag} />)}
{relativeDate && (
<Text secondaryBody text02>
{relativeDate}
</Text>
)}
</div>
</div>
)}
{/* Description */}
{currentSource.description && (
<Text secondaryBody text03 as="span" className="line-clamp-4">
{currentSource.description}
</Text>
)}
</div>
</div>
);
};
const SourceTagDetailsCard = memo(SourceTagDetailsCardInner);
export default SourceTagDetailsCard;

View File

@@ -0,0 +1,6 @@
export {
default as SourceTag,
type SourceTagProps,
type SourceInfo,
} from "./SourceTag";
export { default as SourceTagDetailsCard } from "./SourceTagDetailsCard";

View File

@@ -0,0 +1,99 @@
import { OnyxDocument } from "@/lib/search/interfaces";
import { SubQuestionDetail } from "@/app/chat/interfaces";
import { StreamingCitation } from "@/app/chat/services/streamingModels";
import { ValidSources } from "@/lib/types";
import { getSourceDisplayName } from "@/lib/sources";
import { SourceInfo } from "./SourceTagDetailsCard";
const MAX_TITLE_LENGTH = 40;
function truncateText(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength) + "...";
}
/**
* Convert an OnyxDocument to a SourceInfo object for use with SourceTag
*/
export function documentToSourceInfo(doc: OnyxDocument): SourceInfo {
const sourceType = doc.source_type as ValidSources;
return {
id: doc.document_id,
title: doc.semantic_identifier || "Unknown",
sourceType,
sourceUrl: doc.link,
description: doc.blurb,
metadata: doc.updated_at
? {
date: doc.updated_at,
}
: undefined,
};
}
/**
* Convert a SubQuestionDetail to a SourceInfo object for use with SourceTag
*/
export function questionToSourceInfo(
question: SubQuestionDetail,
index: number
): SourceInfo {
return {
id: `question-${question.level}-${question.level_question_num}`,
title: truncateText(question.question, MAX_TITLE_LENGTH),
sourceType: ValidSources.NotApplicable,
description: question.answer,
isQuestion: true,
questionData: question,
};
}
/**
* Convert an array of citations and document map to SourceInfo array
* Used for end-of-message Sources tag
*/
export function citationsToSourceInfoArray(
citations: StreamingCitation[],
documentMap: Map<string, OnyxDocument>
): SourceInfo[] {
const sources: SourceInfo[] = [];
const seenDocIds = new Set<string>();
for (const citation of citations) {
if (seenDocIds.has(citation.document_id)) continue;
const doc = documentMap.get(citation.document_id);
if (doc) {
seenDocIds.add(citation.document_id);
sources.push(documentToSourceInfo(doc));
}
}
// Fallback: if no citations but we have documents, use first few documents
if (sources.length === 0 && documentMap.size > 0) {
const entries = Array.from(documentMap.entries());
for (const [, doc] of entries) {
sources.push(documentToSourceInfo(doc));
if (sources.length >= 3) break;
}
}
return sources;
}
/**
* Get a display name for a source, used for inline citations
*/
export function getDisplayNameForSource(doc: OnyxDocument): string {
const sourceType = doc.source_type as ValidSources;
if (sourceType === ValidSources.Web || doc.is_internet) {
return truncateText(doc.semantic_identifier || "", MAX_TITLE_LENGTH);
}
return (
getSourceDisplayName(sourceType) ||
truncateText(doc.semantic_identifier || "", MAX_TITLE_LENGTH)
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useState, useMemo, useRef, useLayoutEffect, useEffect } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import Modal from "@/refresh-components/Modal";
import IconButton from "@/refresh-components/buttons/IconButton";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import FadingEdgeContainer from "@/refresh-components/FadingEdgeContainer";
import { SvgDownload, SvgMaximize2, SvgX } from "@opal/icons";
import { cn } from "@/lib/utils";
export interface ExpandableTextDisplayProps {
/** Title shown in header and modal */
title: string;
/** The full text content to display (used in modal and for copy/download) */
content: string;
/** Optional content to display in collapsed view (e.g., for streaming animation). Falls back to `content`. */
displayContent?: string;
/** Subtitle text (e.g., file size). If not provided, calculates from content */
subtitle?: string;
/** Maximum lines to show in collapsed state (1-6). Values outside this range default to 5. */
maxLines?: 1 | 2 | 3 | 4 | 5 | 6;
/** Additional className for the container */
className?: string;
/** Optional custom renderer for content (e.g., markdown). Falls back to plain text. */
renderContent?: (content: string) => React.ReactNode;
/** When true, uses scrollable container with auto-scroll instead of line-clamp */
isStreaming?: boolean;
}
/** Calculate content size in human-readable format */
function getContentSize(text: string): string {
const bytes = new Blob([text]).size;
if (bytes < 1024) return `${bytes} Bytes`;
return `${(bytes / 1024).toFixed(2)} KB`;
}
/** Count lines in text */
function getLineCount(text: string): number {
return text.split("\n").length;
}
/** Download content as a .txt file */
function downloadAsTxt(content: string, filename: string) {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
URL.revokeObjectURL(url);
}
}
/** Approximate line height for max-height calculation in streaming mode */
const LINE_HEIGHT_PX = 20;
export default function ExpandableTextDisplay({
title,
content,
displayContent,
subtitle,
maxLines = 5,
className,
renderContent,
isStreaming = false,
}: ExpandableTextDisplayProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
const textRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const lineCount = useMemo(() => getLineCount(content), [content]);
const contentSize = useMemo(() => getContentSize(content), [content]);
const displaySubtitle = subtitle ?? contentSize;
useLayoutEffect(() => {
// Use scrollRef for streaming mode or when renderContent is provided (max-height approach)
// Use textRef only for plain text with line-clamp
const el =
isStreaming || renderContent ? scrollRef.current : textRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight);
}
}, [content, displayContent, maxLines, isStreaming, renderContent]);
// Auto-scroll to bottom when streaming
// Use useEffect (not useLayoutEffect) to ensure content is fully rendered before scrolling
useEffect(() => {
if (isStreaming && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [isStreaming, content, displayContent]);
const handleDownload = () => {
const sanitizedTitle = title.replace(/[^a-z0-9]/gi, "_").toLowerCase();
downloadAsTxt(content, sanitizedTitle);
};
const lineClampClassMap: Record<number, string> = {
1: "line-clamp-1",
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
};
const lineClampClass = lineClampClassMap[maxLines] ?? "line-clamp-5";
return (
<>
{/* Collapsed View */}
<div className={cn("w-full flex", className)}>
{(() => {
// Build the content element
const contentElement =
isStreaming || renderContent ? (
// Streaming mode: scrollable container with auto-scroll
// Rendered content (markdown): use max-height + overflow-hidden
// (line-clamp uses display: -webkit-box which conflicts with complex HTML)
<div
ref={scrollRef}
className={cn(
isStreaming ? "overflow-y-auto" : "overflow-hidden",
!renderContent && "whitespace-pre-wrap"
)}
style={{ maxHeight: `${maxLines * LINE_HEIGHT_PX}px` }}
>
{renderContent ? (
renderContent(displayContent ?? content)
) : (
<Text as="p" mainUiMuted text03>
{displayContent ?? content}
</Text>
)}
</div>
) : (
// Static mode with plain text: use line-clamp
<div
ref={textRef}
className={cn(lineClampClass, "whitespace-pre-wrap")}
>
<Text as="p" mainUiMuted text03>
{displayContent ?? content}
</Text>
</div>
);
// Wrap with fading edge when truncated (but not when streaming)
return isTruncated && !isStreaming ? (
<FadingEdgeContainer direction="bottom">
{contentElement}
</FadingEdgeContainer>
) : (
contentElement
);
})()}
{/* Expand button - only show when content is truncated */}
{isTruncated && (
<div className="flex items-end mt-1">
<IconButton
internal
icon={SvgMaximize2}
tooltip="View Full Text"
onClick={() => setIsModalOpen(true)}
/>
</div>
)}
</div>
{/* Expanded Modal */}
<Modal open={isModalOpen} onOpenChange={setIsModalOpen}>
<Modal.Content height="lg" width="md-sm" preventAccidentalClose={false}>
{/* Header */}
<div className="flex items-start justify-between px-4 py-3">
<div className="flex flex-col">
<DialogPrimitive.Title asChild>
<Text as="span" text04 headingH3>
{title}
</Text>
</DialogPrimitive.Title>
<DialogPrimitive.Description asChild>
<Text as="span" text03 secondaryBody>
{displaySubtitle}
</Text>
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close asChild>
<IconButton
icon={SvgX}
internal
onClick={() => setIsModalOpen(false)}
/>
</DialogPrimitive.Close>
</div>
{/* Body */}
<Modal.Body>
{renderContent ? (
renderContent(content)
) : (
<Text as="p" mainUiMuted text03 className="whitespace-pre-wrap">
{content}
</Text>
)}
</Modal.Body>
{/* Footer */}
<div className="flex items-center justify-between p-2 bg-background-tint-01">
<div className="px-2">
<Text as="span" mainUiMuted text03>
{lineCount} {lineCount === 1 ? "line" : "lines"}
</Text>
</div>
<div className="flex items-center gap-1 bg-background-tint-00 p-1 rounded-12">
<CopyIconButton
internal
getCopyText={() => content}
tooltip="Copy"
/>
<IconButton
internal
icon={SvgDownload}
tooltip="Download"
onClick={handleDownload}
/>
</div>
</div>
</Modal.Content>
</Modal>
</>
);
}

View File

@@ -4,7 +4,7 @@ import React from "react";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import IconButton from "@/refresh-components/buttons/IconButton";
import FadeDiv from "@/components/FadeDiv";
import FadingEdgeContainer from "@/refresh-components/FadingEdgeContainer";
import ToolItemSkeleton from "@/sections/actions/skeleton/ToolItemSkeleton";
import EnabledCount from "@/refresh-components/EnabledCount";
import { SvgEye, SvgXCircle } from "@opal/icons";
@@ -57,19 +57,18 @@ const ToolsList: React.FC<ToolsListProps> = ({
return (
<>
<div
<FadingEdgeContainer
direction="bottom"
className={cn(
"flex flex-col gap-1 items-start max-h-[30vh] overflow-y-auto w-full",
"flex flex-col gap-1 items-start max-h-[30vh] overflow-y-auto",
className
)}
>
{isFetching ? (
// Show 5 skeleton items while loading
Array.from({ length: 5 }).map((_, index) => (
<ToolItemSkeleton key={`skeleton-${index}`} />
))
) : isEmpty ? (
// Empty state
<div className="flex items-center justify-center w-full py-8">
<Text as="p" text03 mainUiBody>
{searchQuery ? emptySearchMessage : emptyMessage}
@@ -78,11 +77,11 @@ const ToolsList: React.FC<ToolsListProps> = ({
) : (
children
)}
</div>
</FadingEdgeContainer>
{/* Footer showing enabled tool count with filter toggle */}
{showFooter && !(totalCount === 0) && !isFetching && (
<FadeDiv>
<div className="pt-2 px-2">
<div className="flex items-center justify-between gap-2 w-full">
{/* Left action area */}
{leftAction}
@@ -128,7 +127,7 @@ const ToolsList: React.FC<ToolsListProps> = ({
)}
</div>
</div>
</FadeDiv>
</div>
)}
</>
);

View File

@@ -16,6 +16,10 @@ module.exports = {
spacing: "margin, padding",
},
keyframes: {
shimmer: {
"0%": { backgroundPosition: "100% 0" },
"100%": { backgroundPosition: "-100% 0" },
},
"subtle-pulse": {
"0%, 100%": { opacity: 0.9 },
"50%": { opacity: 0.5 },
@@ -42,6 +46,7 @@ module.exports = {
},
},
animation: {
shimmer: "shimmer 1.8s ease-out infinite",
"fade-in-up": "fadeInUp 0.5s ease-out",
"subtle-pulse": "subtle-pulse 2s ease-in-out infinite",
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",