mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-06 23:42:44 +00:00
Compare commits
40 Commits
cli/v0.2.1
...
agent-mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f3d3ad5c | ||
|
|
727c7d708a | ||
|
|
5415a73d9e | ||
|
|
239bc2d6a6 | ||
|
|
3bcad459ab | ||
|
|
ae4a2aa8c9 | ||
|
|
384fbea1ec | ||
|
|
8fca361ace | ||
|
|
c97d245862 | ||
|
|
5bb338e80e | ||
|
|
9565dbfe24 | ||
|
|
a5710bd14c | ||
|
|
fdd720b124 | ||
|
|
42a3dff826 | ||
|
|
33f9d644fe | ||
|
|
194d583e91 | ||
|
|
2a6ebdd83f | ||
|
|
5736a226e6 | ||
|
|
a39a4910c6 | ||
|
|
18dd1cacc6 | ||
|
|
a7ae573810 | ||
|
|
7692de095a | ||
|
|
8c7897e054 | ||
|
|
cf5fe7e44a | ||
|
|
b4edf5442c | ||
|
|
54065aef75 | ||
|
|
eb1028d255 | ||
|
|
10eeaf3852 | ||
|
|
5f97b23499 | ||
|
|
56c63e8868 | ||
|
|
0e139413d1 | ||
|
|
6837417d76 | ||
|
|
34320c98c9 | ||
|
|
54e110ef13 | ||
|
|
9d41d40c24 | ||
|
|
d71ec1d730 | ||
|
|
626d5aed7e | ||
|
|
c76514d8e5 | ||
|
|
b8b3b3e5f0 | ||
|
|
a62239e477 |
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
web/lib/opal/src/icons/branch.tsx
Normal file
21
web/lib/opal/src/icons/branch.tsx
Normal 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;
|
||||
16
web/lib/opal/src/icons/circle.tsx
Normal file
16
web/lib/opal/src/icons/circle.tsx
Normal 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;
|
||||
21
web/lib/opal/src/icons/download.tsx
Normal file
21
web/lib/opal/src/icons/download.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
22
web/lib/opal/src/icons/terminal.tsx
Normal file
22
web/lib/opal/src/icons/terminal.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -751,6 +751,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
|
||||
: projectContextTokenCount
|
||||
}
|
||||
availableContextTokens={availableContextTokens}
|
||||
chatSessionId={currentChatSessionId}
|
||||
selectedAssistant={selectedAssistant || liveAssistant}
|
||||
handleFileUpload={handleMessageSpecificFileUpload}
|
||||
setPresentingDocument={setPresentingDocument}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
232
web/src/app/chat/message/messageComponents/AgentMessage.tsx
Normal file
232
web/src/app/chat/message/messageComponents/AgentMessage.tsx
Normal 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;
|
||||
323
web/src/app/chat/message/messageComponents/MessageToolbar.tsx
Normal file
323
web/src/app/chat/message/messageComponents/MessageToolbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
64
web/src/refresh-components/FadingEdgeContainer.tsx
Normal file
64
web/src/refresh-components/FadingEdgeContainer.tsx
Normal 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;
|
||||
@@ -75,6 +75,7 @@ const useModalContext = () => {
|
||||
const widthClasses = {
|
||||
lg: "w-[80dvw]",
|
||||
md: "w-[60rem]",
|
||||
"md-sm": "w-[40rem]",
|
||||
sm: "w-[32rem]",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
236
web/src/refresh-components/buttons/source-tag/SourceTag.tsx
Normal file
236
web/src/refresh-components/buttons/source-tag/SourceTag.tsx
Normal 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;
|
||||
@@ -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;
|
||||
6
web/src/refresh-components/buttons/source-tag/index.ts
Normal file
6
web/src/refresh-components/buttons/source-tag/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
default as SourceTag,
|
||||
type SourceTagProps,
|
||||
type SourceInfo,
|
||||
} from "./SourceTag";
|
||||
export { default as SourceTagDetailsCard } from "./SourceTagDetailsCard";
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
239
web/src/refresh-components/texts/ExpandableTextDisplay.tsx
Normal file
239
web/src/refresh-components/texts/ExpandableTextDisplay.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user