Compare commits

...

38 Commits

Author SHA1 Message Date
SubashMohan
4ac08fc28a refactor(chat): optimize memoization and rendering in message components, enhance state management in usePacketProcessor 2026-01-19 20:04:45 +05:30
SubashMohan
60be22c63b refactor agenttimeline 2026-01-19 12:19:28 +05:30
SubashMohan
5fbfd1c54f refactor(chat): add supportsCompact property to message renderers for improved compact mode handling 2026-01-19 10:01:36 +05:30
SubashMohan
b3e03fcbab Merge branch 'main' into refactor/ai-message-2 2026-01-18 21:32:08 +05:30
SubashMohan
d79d52a9fe refactor(chat): optimize rendering performance and improve memoization in message components 2026-01-18 21:19:01 +05:30
SubashMohan
193cc6f1db refactor(chat): improve session loading and timeline rendering 2026-01-18 20:27:09 +05:30
SubashMohan
55439c734d refactor(chat): enhance message rendering with isLastStep and isFirstStep properties 2026-01-18 19:31:59 +05:30
SubashMohan
cea35d7d9c feat(chat): enhance tool tracking and display in AgentTimeline 2026-01-18 16:40:04 +05:30
SubashMohan
f8c2fc58a4 add shimmering effect 2026-01-18 14:48:32 +05:30
SubashMohan
60fa837216 refactor(chat): enhance ResearchAgentRenderer and AgentTimeline for improved rendering 2026-01-18 14:30:35 +05:30
SubashMohan
16c00fbb5f refactor(chat): enhance ResearchAgentRenderer and update ParallelTimelineTabs
- Refactored ResearchAgentRenderer to utilize StepContainer and TimelineRendererComponent for improved rendering of research tasks and nested tool calls.
- Simplified the rendering logic by removing unnecessary components and integrating ExpandableTextDisplay for report content.
- Updated ParallelTimelineTabs to conditionally render StepContainer based on packet type, ensuring compatibility with ResearchAgentRenderer's layout.
- Improved overall code organization and readability.
2026-01-17 20:53:57 +05:30
SubashMohan
a7f12eeac1 feat(icons): add SvgCircle and SvgDownload icons; update icon exports
- Introduced SvgCircle and SvgDownload components for enhanced iconography.
- Updated index.ts to export new icons, ensuring they are available for use throughout the application.
- Refactored DeepResearchPlanRenderer and ReasoningRenderer to utilize ExpandableTextDisplay for improved content presentation.
- Added sharedMarkdownComponents for consistent markdown rendering across components.
- Implemented ExpandableTextDisplay component for better handling of expandable text content.
2026-01-17 19:52:08 +05:30
SubashMohan
328513877a feat(chat): add SvgBranch icon and implement ParallelTimelineTabs component
- Introduced SvgBranch icon for enhanced visual representation in the application.
- Added ParallelTimelineTabs component to manage parallel steps in the chat timeline, improving user experience with tabbed navigation.
- Updated AgentTimeline to integrate ParallelTimelineTabs, allowing for better organization of chat steps.
- Enhanced Tabs component with new features for improved styling and functionality.
2026-01-17 16:31:01 +05:30
SubashMohan
92ca6f2830 update icons 2026-01-17 14:37:02 +05:30
SubashMohan
b08570ef43 refactor(chat): enhance PythonToolRenderer and introduce FadeDiv component 2026-01-17 13:28:27 +05:30
SubashMohan
818fffdaa1 change conditional render 2026-01-17 12:37:10 +05:30
SubashMohan
38084b676e remove unwanted comments 2026-01-17 12:22:00 +05:30
SubashMohan
e676915711 refactor(chat): enhance rendering logic in FetchToolRenderer and SearchToolRenderer
- Added COMPACT render type to improve rendering flexibility in FetchToolRenderer and SearchToolRenderer.
- Updated components to conditionally display content based on the render type, optimizing the user interface for different viewing preferences.
- Refactored SearchChipList rendering to be hidden in compact mode, streamlining the display of search results and fetch states.
2026-01-17 12:12:23 +05:30
SubashMohan
f9454cfa7f refactor(chat): remove iconRegistry and related functions
- Deleted iconRegistry file and its associated functions for icon and name retrieval to simplify the codebase.
- Updated timeline components to remove references to the deleted functions, ensuring continued functionality without them.
2026-01-17 11:57:53 +05:30
SubashMohan
afdc581633 refactor(chat): remove unused packet group helper functions
- Deleted isToolPacketGroup and isDisplayPacketGroup functions from iconRegistry to simplify the codebase.
- Updated imports in timeline components to reflect the removal of these functions, ensuring continued functionality.
2026-01-17 11:53:39 +05:30
SubashMohan
64e2771aad refactor(chat): remove AgentStep component and update icon handling
- Deleted the AgentStep component to streamline the timeline structure.
- Moved IconType definition to iconRegistry for better organization and accessibility.
- Updated imports in related components to reflect the removal of AgentStep, ensuring continued functionality.
2026-01-17 11:47:17 +05:30
SubashMohan
2ea63c0d67 refactor(chat): implement TimelineRendererComponent for enhanced rendering logic
- Introduced TimelineRendererComponent to manage the rendering of chat packets with controlled expand/collapse functionality.
- Updated AgentTimeline to utilize TimelineRendererComponent, improving structure and readability.
- Enhanced StepContainer to support controlled expanded state and toggle functionality, streamlining user interaction.
- Removed deprecated RendererComponent in favor of the new TimelineRendererComponent for better maintainability.
2026-01-17 11:38:03 +05:30
SubashMohan
c5f7db6566 refactor(SourceTag): optimize source handling and rendering logic 2026-01-16 15:26:58 +05:30
SubashMohan
6dbcdfb208 refactor(chat): enhance FetchToolRenderer and SearchToolRenderer for improved source handling
- Refactored FetchToolRenderer and SearchToolRenderer to utilize new SourceInfo structure for better source management.
- Updated SearchChipList to support dynamic source information and improved rendering logic.
- Introduced getMetadataTags utility for extracting metadata tags from documents, enhancing source details.
- Simplified icon handling and removed unused icon factories, streamlining the component structure.
- Added SourceTag and SourceTagDetailsCard components for better source representation and interaction.
2026-01-16 15:15:04 +05:30
SubashMohan
27c64c5cdb remove unused things 2026-01-16 10:21:25 +05:30
SubashMohan
adf9f742b6 feat(chat): add packetCount tracking for improved memoization
- Introduced packetCount property in Message and AgentMessageProps interfaces to optimize React memo comparisons by avoiding direct array length checks.
- Updated useChatController to set packetCount based on the length of packets.
- Enhanced AgentMessage and AgentTimeline components to utilize packetCount for more efficient rendering.
- Refactored related components to support the new packetCount logic, improving performance during state updates.
2026-01-15 18:09:23 +05:30
SubashMohan
545866d151 refactor(chat): enhance icon handling in message components
- Refactored CitedSourcesToggle to utilize icon factory functions for improved flexibility in rendering icons.
- Updated FetchToolRenderer, SearchToolRenderer, and related components to adopt the new icon factory pattern, enhancing consistency across the application.
- Modified SearchChipList to support icon factory functions, allowing for dynamic icon rendering based on item properties.
- Improved Tag component to accept icon factories, streamlining the integration of icons in tags.
- Added utility function getUniqueIconFactories to generate unique icon factories for documents, enhancing modularity and reusability.
2026-01-15 15:17:37 +05:30
SubashMohan
324675842f feat(chat): introduce fetch tool components for enhanced data retrieval
- Added FetchToolRenderer and associated utility functions to manage fetch tool packets and display results.
- Implemented state management for fetch operations, including loading and completion states.
- Created expandable list functionality for displaying URLs and documents dynamically.
- Refactored timing hooks to standardize the display duration for active and complete states across tools.
2026-01-15 14:32:19 +05:30
SubashMohan
3623bbe02d feat(chat): implement search tool components for enhanced query handling
- Introduced new components for search functionality, including SearchToolRenderer, SearchChipList, and various step renderers to manage search queries and results.
- Added utility functions and hooks for managing search state and timing, improving user experience during search operations.
- Created a new search state management system to handle queries and results efficiently, ensuring smooth transitions and feedback.
- Implemented expandable list functionality to allow users to view more search results dynamically.
2026-01-15 13:03:12 +05:30
SubashMohan
d0015120a1 refactor(chat): simplify AgentMessage and enhance AgentTimeline for improved rendering
- Replaced the previous layout in AgentMessage with a new AgentTimeline component for better structure and readability.
- Removed deprecated components such as StepContent, ParallelSteps, and TimelineContent to streamline the codebase.
- Updated the usePacketProcessor hook to include additional logging for debugging purposes.
- Enhanced the AgentTimeline component to handle empty states and improve user feedback during loading.
- Adjusted conditional rendering logic in AgentMessage to focus on display groups, enhancing clarity in the rendering process.
2026-01-15 12:20:25 +05:30
SubashMohan
380cdac456 refactor(chat): enhance AgentTimeline and StepContainer components with collapsible functionality
- Added collapsible feature to AgentTimeline and StepContainer for improved user interaction and content management.
- Introduced new props for header and button title to enhance customization options.
- Refactored layout structure for better readability and maintainability.
- Streamlined rendering logic to improve performance and user experience.
2026-01-14 18:19:00 +05:30
SubashMohan
d32dd726b6 refactor(chat): enhance AgentTimeline and StepContent components for improved functionality
- Introduced collapsible functionality in StepContainer, allowing for better content management and user interaction.
- Updated StepContent to remove deprecated props and streamline rendering logic.
- Refactored TimelineContent to utilize StepContainer for consistent layout and improved readability.
- Removed unused StepContentSimple component to simplify the codebase.
- Adjusted renderer registry to eliminate priority-based sorting, enhancing clarity in renderer matching.
2026-01-14 15:52:27 +05:30
SubashMohan
b82e9e196f refactor(chat): improve packet processing and state management in AgentMessage
- Simplified the usePacketProcessor hook by consolidating state management and derived values for better clarity.
- Removed unnecessary state variables and streamlined the completion logic for rendering.
- Updated AgentMessage to utilize the new isComplete flag and onRenderComplete callback for improved feedback during asynchronous operations.
- Enhanced comments for better understanding of the processing flow and architecture.
2026-01-14 14:13:36 +05:30
SubashMohan
fea7021852 refactor(chat): enhance packet processing and component structure in AgentMessage
- Streamlined the packet processing logic by categorizing tool and display groups directly within the processor.
- Removed deprecated useAgentTimeline hook and integrated its functionality into usePacketProcessor for improved performance.
- Updated AgentMessage to utilize new pre-categorized groups, enhancing clarity in rendering logic.
- Adjusted conditional rendering to improve user feedback during asynchronous operations.
2026-01-14 14:00:53 +05:30
SubashMohan
53777bd6e6 style(chat): enhance layout and spacing in AgentMessage and MessageToolbar components
- Added a bottom margin reset for the last child in the prose class to eliminate extra space.
- Adjusted the layout of the AgentMessage component to include a flex column structure and improved padding.
- Refined the MessageToolbar component by removing unnecessary margin for a cleaner appearance.
- Updated conditional rendering logic in AgentMessage for better clarity and user feedback.
2026-01-14 13:06:00 +05:30
SubashMohan
15d110aa7e refactor(chat): streamline AgentMessage and MessageToolbar components
- Removed the AgentTimeline component and integrated its functionality directly into AgentMessage for a more cohesive structure.
- Simplified feedback handling in MessageToolbar by consolidating feedback logic and modal management.
- Introduced new TimelineContent and TimelineIcons components for better modularity and control over timeline rendering.
- Updated imports and adjusted component structure to enhance readability and maintainability.
2026-01-14 12:09:00 +05:30
SubashMohan
85d6634899 feat(timeline): introduce AgentTimeline and related components for enhanced message rendering
- Added AgentTimeline, AgentStep, and supporting components to create a timeline view for agent actions.
- Implemented packet processing utilities to transform and render steps based on packet types.
- Integrated new timeline components into the existing AgentMessage structure for improved user experience.
- Updated Tabs component to support loading states for better feedback during asynchronous operations.
2026-01-13 18:14:05 +05:30
SubashMohan
55040dc23c feat: Add AgentMessage component and related packet processing utilities 2026-01-13 11:11:17 +05:30
61 changed files with 5632 additions and 530 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -859,6 +859,7 @@ export function useChatController({
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
packetCount: packets.length,
},
],
// Pass the latest map state
@@ -885,6 +886,7 @@ export function useChatController({
toolCall: null,
parentNodeId: parentMessage?.nodeId || SYSTEM_NODE_ID,
packets: [],
packetCount: 0,
},
{
nodeId: initialAssistantNode.nodeId,
@@ -894,6 +896,7 @@ export function useChatController({
toolCall: null,
parentNodeId: initialUserNode.nodeId,
packets: [],
packetCount: 0,
stackTrace: stackTrace,
errorCode: errorCode,
isRetryable: isRetryable,

View File

@@ -139,6 +139,7 @@ export interface Message {
// new gen
packets: Packet[];
packetCount?: number; // Tracks packet count for React memo comparison (avoids reading from mutated array)
// cached values for easy access
documents?: OnyxDocument[] | null;

View File

@@ -0,0 +1,987 @@
# AIMessage and MultiToolRenderer Requirements Document
> **Purpose:** This document captures everything the AIMessage.tsx and MultiToolRenderer.tsx components do, including all edge cases, to aid in refactoring.
## Table of Contents
1. [Overview](#1-overview)
2. [AIMessage Component](#2-aimessage-component)
3. [MultiToolRenderer Component](#3-multitoolrenderer-component)
4. [Packet Types and Streaming Models](#4-packet-types-and-streaming-models)
5. [Interfaces](#5-interfaces)
6. [Hooks](#6-hooks)
7. [Utility Functions](#7-utility-functions)
8. [Renderers](#8-renderers)
9. [Edge Cases](#9-edge-cases)
10. [Data Flow](#10-data-flow)
11. [State Management](#11-state-management)
12. [Timing Logic](#12-timing-logic)
13. [Citations and Documents](#13-citations-and-documents)
---
## 1. Overview
### Purpose
These components render AI responses in the Onyx chat interface, handling:
- Streaming message content with real-time updates
- Tool execution display (search, code, image generation, etc.)
- Parallel tool support
- Citations and document references
- Feedback collection (like/dislike)
- Message regeneration and switching
### Architecture Pattern
```
rawPackets (backend stream)
|
AIMessage (incremental processing)
|
Groups packets by (turn_index, tab_index)
|
Split into:
+-- toolGroups --> MultiToolRenderer
| +-- Timeline of tool executions
+-- displayGroups --> RendererComponent
+-- Main message content
```
---
## 2. AIMessage Component
### Location
`/web/src/app/chat/message/messageComponents/AIMessage.tsx`
### Props (AIMessageProps)
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `rawPackets` | `Packet[]` | Yes | Streaming packets from backend |
| `chatState` | `FullChatState` | Yes | Assistant, docs, citations, model info |
| `nodeId` | `number` | Yes | Unique ID in message tree |
| `messageId` | `number` | No | ID for feedback/regeneration |
| `currentFeedback` | `FeedbackType \| null` | No | Current like/dislike state |
| `llmManager` | `LlmManager \| null` | Yes | LLM selection manager |
| `otherMessagesCanSwitchTo` | `number[]` | No | Alternative message node IDs |
| `onMessageSelection` | `(nodeId: number) => void` | No | Message switch callback |
| `onRegenerate` | `RegenerationFactory` | No | Regeneration factory function |
| `parentMessage` | `Message \| null` | No | Parent for regeneration context |
### RegenerationFactory Type
```typescript
type RegenerationFactory = (regenerationRequest: {
messageId: number;
parentMessage: Message;
forceSearch?: boolean;
}) => (modelOverride: LlmDescriptor) => Promise<void>;
```
### Memoization
Uses `React.memo` with custom `arePropsEqual` function comparing:
- `nodeId`, `messageId`, `currentFeedback`
- `rawPackets.length` (assumes append-only)
- `chatState.assistant?.id`, `chatState.docs`, `chatState.citations`
- `chatState.overriddenModel`, `chatState.researchType`
- `otherMessagesCanSwitchTo`, `onRegenerate`
- `parentMessage?.messageId`, `llmManager?.isLoadingProviders`
### Internal State
| State/Ref | Type | Purpose |
|-----------|------|---------|
| `lastProcessedIndexRef` | `number` | Tracks processed packet index |
| `citationsRef` | `StreamingCitation[]` | Accumulates citations |
| `seenCitationDocIdsRef` | `Set<string>` | Citation deduplication |
| `citationMapRef` | `CitationMap` | citation_num -> document_id |
| `documentMapRef` | `Map<string, OnyxDocument>` | Document storage by ID |
| `groupedPacketsMapRef` | `Map<string, Packet[]>` | Groups by "turn-tab" key |
| `groupedPacketsRef` | `Array<{turn_index, tab_index, packets}>` | Sorted groups |
| `finalAnswerComingRef` | `boolean` | Message content incoming |
| `displayCompleteRef` | `boolean` | All content rendered |
| `stopPacketSeenRef` | `boolean` | STOP packet received |
| `stopReasonRef` | `StopReason` | Why stream stopped |
| `seenGroupKeysRef` | `Set<string>` | Tracked group keys |
| `groupKeysWithSectionEndRef` | `Set<string>` | Groups with SECTION_END |
| `expectedBranchesRef` | `Map<number, number>` | Expected parallel branches per turn |
### Key Behaviors
#### 1. Incremental Packet Processing
- Processes only NEW packets (from `lastProcessedIndexRef.current`)
- Resets all state when `nodeId` changes
- Resets if `rawPackets.length < lastProcessedIndexRef.current`
#### 2. Packet Grouping
- Groups by composite key: `"${turn_index}-${tab_index}"`
- Same turn_index + different tab_index = parallel tools
- TOP_LEVEL_BRANCHING packets set expected branch count (not grouped)
#### 3. Synthetic SECTION_END Injection
- Injects SECTION_END when moving to a new turn_index
- Injects for all groups when STOP packet arrives
- Ensures graceful tool completion
#### 4. Content Splitting
```typescript
// Tools go to MultiToolRenderer
const toolGroups = groupedPackets.filter(g => isToolPacket(g.packets[0], false));
// Display content (messages, images, python) go to main area
const displayGroups = (finalAnswerComing || toolGroups.length === 0)
? groupedPackets.filter(g => isDisplayPacket(g.packets[0]))
: [];
```
#### 5. Citation Processing
- Extracts CITATION_INFO packets immediately
- Adds to `citationMapRef` for real-time rendering
- Deduplicates using `seenCitationDocIdsRef`
#### 6. Document Extraction
- From SEARCH_TOOL_DOCUMENTS_DELTA packets
- From FETCH_TOOL_DOCUMENTS packets
- Stored in `documentMapRef`
#### 7. Feedback Handling
- Toggle logic: clicking same button removes feedback
- Like: Opens modal if NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS set
- Dislike: Always opens FeedbackModal
#### 8. UI Elements Rendered
- AgentAvatar for assistant
- MultiToolRenderer for tools (if any)
- RendererComponent for display content
- Feedback buttons (when complete)
- Copy button
- Message switcher (when alternatives exist)
- Regenerate/LLM popover
- CitedSourcesToggle
---
## 3. MultiToolRenderer Component
### Location
`/web/src/app/chat/message/messageComponents/MultiToolRenderer.tsx`
### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `packetGroups` | `Array<{turn_index, tab_index, packets}>` | Yes | Tool packet groups |
| `chatState` | `FullChatState` | Yes | Chat state for renderers |
| `isComplete` | `boolean` | Yes | All tools finished |
| `isFinalAnswerComing` | `boolean` | Yes | Final answer expected |
| `stopPacketSeen` | `boolean` | Yes | STOP packet received |
| `stopReason` | `StopReason` | No | Why stopped (finished/cancelled) |
| `onAllToolsDisplayed` | `() => void` | No | Callback when all visible |
| `isStreaming` | `boolean` | No | Global streaming state |
| `expectedBranchesPerTurn` | `Map<number, number>` | No | Expected parallel branches |
### Internal Types
```typescript
enum DisplayType {
REGULAR = "regular", // Standard tool
SEARCH_STEP_1 = "search-step-1", // Internal search: querying
SEARCH_STEP_2 = "search-step-2", // Internal search: reading docs
}
type DisplayItem = {
key: string; // "turn-tab" or "turn-tab-search-1/2"
type: DisplayType;
turn_index: number;
tab_index: number;
packets: Packet[];
};
```
### Internal State
| State | Type | Purpose |
|-------|------|---------|
| `isExpanded` | `boolean` | Summary expanded (complete state) |
| `isStreamingExpanded` | `boolean` | Expanded during streaming |
### Key Behaviors
#### 1. Display Item Transformation
- Internal search tools split into SEARCH_STEP_1 + SEARCH_STEP_2
- SEARCH_STEP_2 only added if `hasResults || isComplete`
- Other tools remain as single REGULAR items
#### 2. Tool Visibility Control (via useToolDisplayTiming)
- Shows tools sequentially by turn_index
- Parallel tools (same turn, different tab) shown together
- Enforces minimum 1500ms display per tool
#### 3. Shimmer Control
```typescript
const shouldStopShimmering = stopPacketSeen || isStreaming === false || isComplete;
```
#### 4. Two Render Modes
**Streaming Mode (isComplete = false):**
- Shows progressively visible tools
- Uses ToolItemRow for compact display
- Parallel tools rendered via ParallelToolTabs
- Border with rounded corners, padding, shadow
**Complete Mode (isComplete = true):**
- Shows "{n} steps" summary header
- Click to expand/collapse
- Shows all tools with ExpandedToolItem
- "Done" node at bottom (or "Completed with errors")
#### 5. ParallelToolTabs Sub-component
- Tabbed interface for parallel tools
- Tab bar shows name, icon, status indicator
- Navigation arrows (< >) for tab switching
- Keyboard navigation (ArrowLeft/Right)
- Collapse/expand toggle
- Status icons: spinner (loading), checkmark (done), X (error/cancelled)
### Rendered Elements
- Timeline connector lines between tools
- Tool icons and status text
- Expandable content areas
- Branch icon (FiGitBranch) for parallel tools
- Completion indicators
---
## 4. Packet Types and Streaming Models
### Location
`/web/src/app/chat/services/streamingModels.ts`
### Placement Structure
```typescript
interface Placement {
turn_index: number; // Sequential execution order
tab_index?: number; // Parallel tool identifier
sub_turn_index?: number | null; // Nested tool within research agent
}
```
### PacketType Enum (Complete List)
**Message Packets:**
- `MESSAGE_START` - Initial message with final_documents
- `MESSAGE_DELTA` - Streamed content chunk
- `MESSAGE_END` - Message complete
**Control Packets:**
- `STOP` - Stream ended (with stop_reason)
- `SECTION_END` - Tool/section complete
- `TOP_LEVEL_BRANCHING` - Announces parallel branches
- `ERROR` - Tool error with message
**Search Tool:**
- `SEARCH_TOOL_START` - Start with is_internet_search flag
- `SEARCH_TOOL_QUERIES_DELTA` - Search queries
- `SEARCH_TOOL_DOCUMENTS_DELTA` - Found documents
**Python Tool:**
- `PYTHON_TOOL_START` - Code to execute
- `PYTHON_TOOL_DELTA` - stdout, stderr, file_ids
**Image Generation:**
- `IMAGE_GENERATION_TOOL_START` - Generation started
- `IMAGE_GENERATION_TOOL_DELTA` - Generated images
**Fetch/URL Tool:**
- `FETCH_TOOL_START` - URL fetching started
- `FETCH_TOOL_URLS` - URLs being fetched
- `FETCH_TOOL_DOCUMENTS` - Extracted documents
**Custom Tool:**
- `CUSTOM_TOOL_START` - Custom tool name
- `CUSTOM_TOOL_DELTA` - Tool response data
**Reasoning:**
- `REASONING_START` - Thinking started
- `REASONING_DELTA` - Thinking content
- `REASONING_DONE` - Thinking complete
**Citations:**
- `CITATION_INFO` - citation_number -> document_id
**Deep Research:**
- `DEEP_RESEARCH_PLAN_START/DELTA` - Plan generation
- `RESEARCH_AGENT_START` - Agent task
- `INTERMEDIATE_REPORT_START/DELTA` - Agent report
- `INTERMEDIATE_REPORT_CITED_DOCS` - Report citations
### StopReason Enum
```typescript
enum StopReason {
FINISHED = "finished",
USER_CANCELLED = "user_cancelled"
}
```
---
## 5. Interfaces
### Location
`/web/src/app/chat/message/messageComponents/interfaces.ts`
### FullChatState
```typescript
interface FullChatState {
assistant: MinimalPersonaSnapshot;
docs?: OnyxDocument[] | null;
userFiles?: ProjectFile[];
citations?: CitationMap;
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
regenerate?: (modelOverride: LlmDescriptor) => Promise<void>;
overriddenModel?: string;
researchType?: string | null;
}
```
### RendererResult
```typescript
interface RendererResult {
icon: IconType | OnyxIconType | null;
status: string | JSX.Element | null;
content: JSX.Element;
expandedText?: JSX.Element; // Override for expanded view
}
```
### MessageRenderer Type
```typescript
type MessageRenderer<T extends Packet, S extends Partial<FullChatState>> =
React.ComponentType<{
packets: T[];
state: S;
onComplete: () => void;
renderType: RenderType;
animate: boolean;
stopPacketSeen: boolean;
children: (result: RendererResult) => JSX.Element;
}>;
```
### RenderType Enum
```typescript
enum RenderType {
HIGHLIGHT = "highlight", // Short/collapsed
FULL = "full", // Detailed view
}
```
### CitationMap
```typescript
type CitationMap = { [citation_num: number]: string };
```
### StreamingCitation
```typescript
interface StreamingCitation {
citation_num: number;
document_id: string;
}
```
---
## 6. Hooks
### useMessageSwitching
**Location:** `hooks/useMessageSwitching.ts`
```typescript
interface UseMessageSwitchingProps {
nodeId: number;
otherMessagesCanSwitchTo?: number[];
onMessageSelection?: (messageId: number) => void;
}
interface UseMessageSwitchingReturn {
currentMessageInd: number | undefined;
includeMessageSwitcher: boolean;
getPreviousMessage: () => number | undefined;
getNextMessage: () => number | undefined;
}
```
**Behavior:**
- Finds current message index in alternatives
- Shows switcher if alternatives > 1
- Handles circular navigation
### useToolDisplayTiming
**Location:** `hooks/useToolDisplayTiming.ts`
```typescript
function useToolDisplayTiming(
toolGroups: Array<{turn_index, tab_index, packets}>,
isFinalAnswerComing: boolean,
isComplete: boolean,
expectedBranchesPerTurn?: Map<number, number>
): {
visibleTools: Set<string>; // "turn-tab" keys
handleToolComplete: (turnIndex, tabIndex?) => void;
allToolsDisplayed: boolean;
}
```
**Constants:**
- `MINIMUM_DISPLAY_TIME_MS = 1500`
**Behavior:**
- Shows tools sequentially by turn_index
- Parallel tools (same turn) shown together
- Waits for expected branches before advancing
- Enforces minimum display duration
- Tracks completion state
### usePacketAnimationAndCollapse
**Location:** `hooks/usePacketAnimationAndCollapse.ts`
```typescript
function usePacketAnimationAndCollapse({
packets: Packet[];
animate: boolean;
isComplete: boolean;
onComplete: () => void;
preventDoubleComplete?: boolean;
}): {
displayedPacketCount: number; // -1 = all
isExpanded: boolean;
toggleExpanded: () => void;
}
```
**Constants:**
- `PACKET_DELAY_MS = 10`
**Behavior:**
- Gradually reveals packets during animation
- Auto-collapses on completion
- Prevents double onComplete calls
### useFeedbackController
**Location:** `/web/src/app/chat/hooks/useFeedbackController.ts`
```typescript
interface UseFeedbackControllerProps {
setPopup: (popup: PopupSpec | null) => void;
}
function useFeedbackController(props): {
handleFeedbackChange: (
messageId: number,
newFeedback: FeedbackType | null,
feedbackText?: string,
predefinedFeedback?: string
) => Promise<boolean>
}
```
**Behavior:**
- Optimistic UI updates
- Calls backend API
- Rollback on error
- Shows popup on failure
### Store Hooks (useChatSessionStore)
**Location:** `/web/src/app/chat/stores/useChatSessionStore.ts`
Used hooks:
- `useCurrentChatState()` -> "streaming" | "input" | ...
- `useDocumentSidebarVisible()` -> boolean
- `useSelectedNodeForDocDisplay()` -> number | null
- `updateCurrentDocumentSidebarVisible(visible)`
- `updateCurrentSelectedNodeForDocDisplay(nodeId)`
---
## 7. Utility Functions
### packetUtils.ts
**Location:** `/web/src/app/chat/services/packetUtils.ts`
| Function | Description |
|----------|-------------|
| `isToolPacket(packet, includeSectionEnd?)` | Checks if packet is any tool type |
| `isActualToolCallPacket(packet)` | Tool packet excluding reasoning |
| `isDisplayPacket(packet)` | MESSAGE_START, IMAGE_GENERATION, PYTHON |
| `isFinalAnswerComing(packets)` | Checks for display packet types |
| `isStreamingComplete(packets)` | Checks for STOP packet |
| `getTextContent(packets)` | Extracts all message text |
| `groupPacketsByTurnIndex(packets)` | Groups by turn/tab |
### toolDisplayHelpers.tsx
**Location:** `messageComponents/toolDisplayHelpers.tsx`
| Function | Description |
|----------|-------------|
| `parseToolKey(key)` | Parses "turn-tab" to {turn_index, tab_index} |
| `getToolKey(turn_index, tab_index)` | Creates "turn-tab" key |
| `getToolName(packets)` | Human-readable tool name |
| `getToolIcon(packets)` | Tool icon component |
| `isToolComplete(packets)` | Checks SECTION_END/ERROR |
| `hasToolError(packets)` | Checks for ERROR packet |
**getToolName Mapping:**
- SEARCH_TOOL_START -> "Web Search" or "Internal Search"
- PYTHON_TOOL_START -> "Code Interpreter"
- FETCH_TOOL_START -> "Open URLs"
- CUSTOM_TOOL_START -> Custom name
- IMAGE_GENERATION_TOOL_START -> "Generate Image"
- DEEP_RESEARCH_PLAN_START -> "Generate plan"
- RESEARCH_AGENT_START -> "Research agent"
- REASONING_START -> "Thinking"
**isToolComplete Special Case:**
- For RESEARCH_AGENT_START: Only parent-level SECTION_END (sub_turn_index === undefined)
- Prevents marking complete when nested tool finishes
### thinkingTokens.ts
**Location:** `/web/src/app/chat/services/thinkingTokens.ts`
| Function | Description |
|----------|-------------|
| `removeThinkingTokens(content)` | Removes `<think>...</think>` tags |
| `hasCompletedThinkingTokens(content)` | Check for complete blocks |
| `hasPartialThinkingTokens(content)` | Check for streaming blocks |
| `extractThinkingContent(content)` | Get thinking text |
| `isThinkingComplete(content)` | Check if tags balanced |
### copyingUtils.tsx
**Location:** `messageComponents/copyingUtils.tsx`
| Function | Description |
|----------|-------------|
| `handleCopy(event, markdownRef)` | Custom copy with HTML preservation |
| `convertMarkdownTablesToTsv(content)` | Tables to TSV for spreadsheets |
| `copyAll(content)` | Copy with HTML + plain text |
---
## 8. Renderers
### Location
`/web/src/app/chat/message/messageComponents/renderers/`
### RendererComponent (Router)
**Location:** `renderMessageComponent.tsx`
**findRenderer() Selection Order:**
1. MessageTextRenderer - MESSAGE_START/DELTA/END
2. DeepResearchPlanRenderer - Deep research plan
3. ResearchAgentRenderer - Research agent
4. SearchToolRenderer - Search tool
5. ImageToolRenderer - Image generation
6. PythonToolRenderer - Python execution
7. CustomToolRenderer - Custom tools
8. FetchToolRenderer - URL fetching
9. ReasoningRenderer - Thinking/reasoning
### MessageTextRenderer
- Animated packet-by-packet display (10ms delay)
- BlinkingDot during streaming
- Markdown rendering with citations
- Waits for animation before onComplete
### SearchToolRenderer
- Differentiates web vs internal search
- Two-step display: querying -> reading
- Minimum display duration (1000ms each)
- Expandable query/document lists
- Constants: INITIAL_QUERIES_TO_SHOW=3, QUERIES_PER_EXPANSION=5
- Constants: INITIAL_RESULTS_TO_SHOW=3, RESULTS_PER_EXPANSION=10
### FetchToolRenderer
- Three stages: start -> URLs -> documents
- Minimum display duration (1000ms)
- Falls back to URLs if no documents
### ReasoningRenderer
- Minimum display time (500ms)
- Provides expandedText override
- No icon in header
### ImageToolRenderer
- HIGHLIGHT vs FULL render modes
- Loading animation during generation
- 1-2 column grid for images
### PythonToolRenderer
- Syntax highlighting (highlight.js)
- Shows code, stdout, stderr, files
- Error highlighting for failures
### CustomToolRenderer
- JSON/text data responses
- File downloads support
- Image, CSV, generic responses
### DeepResearchPlanRenderer
- Auto-collapse on completion
- Markdown plan content
- Collapsible chevron
### ResearchAgentRenderer
- Separates parent from nested tools
- Recursive RendererComponent for nested
- Shows task, tools, report
- Step count in collapsed view
---
## 9. Edge Cases
### User Cancellation
```typescript
const isCancelled = stopReason === StopReason.USER_CANCELLED;
```
- Shows X icon instead of checkmark
- Stops shimmer immediately
- Skips minimum display timing for search tools
- Passed to all ToolItemRow components
### Tool Errors
- ERROR packet treated as completion
- Shows red X icon in tabs
- "Completed with errors" in Done node
- Tool still displays content
### Loading States
```typescript
const isLoading = !isItemComplete && !shouldStopShimmering;
```
- Shimmer classes: `text-shimmer-base`, `loading-text`
- LoadingSpinner in inactive tabs
- BlinkingDot in empty slots
### Research Agents with Nested Tools
- Nested tools have sub_turn_index set
- Only parent SECTION_END marks agent complete
- Prevents premature completion detection
### Parallel Tools with Expected Branches
- TOP_LEVEL_BRANCHING declares count
- useToolDisplayTiming waits for all branches
- Tools grouped visually with branch icon
### Internal vs Internet Search
- Internal: Split into two display steps
- Internet: Single REGULAR display
- Determined by is_internet_search flag
### Search with No Results
- Shows queries but no documents
- BlinkingDot where results would be
- X icon if cancelled
### Stream Reset
```typescript
if (lastProcessedIndexRef.current > rawPackets.length) {
resetState();
}
```
- Handles when packets replaced with shorter array
- Full state reset occurs
### Final Answer Reset
```typescript
if (finalAnswerComingRef.current && !stopPacketSeenRef.current &&
isActualToolCallPacket(packet)) {
setFinalAnswerComing(false);
setDisplayComplete(false);
}
```
- When message followed by tool call (not reasoning)
- Hides message content until tools complete
### Empty Packet Groups
- Filtered out if no content packets
- Content packets: MESSAGE_START, tool starts, etc.
### Feedback Toggle
- Clicking same button removes feedback
- Like may open modal if predefined options exist
- Dislike always opens modal
### Document Sidebar Toggle
- Same message click closes sidebar
- Different message click switches content
---
## 10. Data Flow
### Complete Flow Diagram
```
Backend SSE Stream
|
rawPackets[] array
|
AIMessage Component
|
+-- Incremental Processing Loop
| +-- Skip TOP_LEVEL_BRANCHING (store in expectedBranchesRef)
| +-- Group by "turn_index-tab_index"
| +-- Extract CITATION_INFO -> citationMapRef
| +-- Extract documents -> documentMapRef
| +-- Track finalAnswerComing state
| +-- Inject SECTION_END at turn boundaries
| +-- Handle STOP packet (inject all SECTION_ENDs)
|
+-- groupedPacketsRef (sorted by turn, tab)
|
+-- effectiveChatState (merge streaming citations)
|
+-- Split packets:
| +-- toolGroups (isToolPacket) ----------------+
| +-- displayGroups (isDisplayPacket) ------+ |
| | |
| +------------------------------------------+ |
| | |
| v |
| RendererComponent |
| +-- findRenderer() -> MessageTextRenderer |
| | -> ImageToolRenderer |
| | -> PythonToolRenderer |
| +-- Children callback |
| |
| +----------------------------------------------+
| |
| v
| MultiToolRenderer
| +-- Transform to DisplayItems
| | +-- Internal search -> STEP_1 + STEP_2
| | +-- Other tools -> REGULAR
| |
| +-- useToolDisplayTiming()
| | +-- visibleTools Set
| | +-- handleToolComplete callback
| | +-- allToolsDisplayed flag
| |
| +-- Streaming View (isComplete=false)
| | +-- visibleTurnGroups
| | +-- Parallel -> ParallelToolTabs
| | +-- Single -> ToolItemRow + Renderer
| |
| +-- Complete View (isComplete=true)
| +-- "{n} steps" summary
| +-- Expanded: ExpandedToolItem + Done
|
+-- UI Controls
+-- FeedbackModal (like/dislike)
+-- MessageSwitcher (alternatives)
+-- CopyIconButton
+-- LLMPopover (regeneration)
+-- CitedSourcesToggle
```
### Packet Processing Order
1. TOP_LEVEL_BRANCHING -> expectedBranchesRef
2. Group packet by key
3. CITATION_INFO -> citationMapRef + citationsRef
4. SEARCH_TOOL_DOCUMENTS_DELTA -> documentMapRef
5. FETCH_TOOL_DOCUMENTS -> documentMapRef
6. Display packet types -> set finalAnswerComing
7. STOP -> set stopPacketSeen, inject SECTION_ENDs
---
## 11. State Management
### AIMessage State Lifecycle
**On Mount / nodeId Change:**
```typescript
resetState() -> Clear all refs to initial values
```
**Per Render (new packets):**
```typescript
for (i = lastProcessedIndex; i < rawPackets.length; i++) {
// Process packet
}
lastProcessedIndexRef.current = rawPackets.length;
```
**State Transitions:**
```
Initial -> (MESSAGE_START) -> finalAnswerComing=true
finalAnswerComing -> (tool packet) -> finalAnswerComing=false
finalAnswerComing -> (STOP) -> stopPacketSeen=true
stopPacketSeen -> (render complete) -> displayComplete=true
```
### MultiToolRenderer State Lifecycle
**Tool Visibility:**
```
useToolDisplayTiming manages visibleTools Set
+-- Start: First turn's tools visible
+-- handleToolComplete called
+-- Check elapsed time >= 1500ms
| +-- Yes: Mark complete, show next turn
| +-- No: Schedule timeout for remaining time
+-- allToolsDisplayed when all turns visible + complete
```
**Expansion State:**
```
Streaming: isStreamingExpanded
| (isComplete becomes true)
Complete: isExpanded = isStreamingExpanded (preserved)
```
---
## 12. Timing Logic
### Tool Display Timing Constants
```typescript
MINIMUM_DISPLAY_TIME_MS = 1500 // Per tool minimum
```
### Search Renderer Timing
```typescript
SEARCHING_MIN_DURATION_MS = 1000 // "Searching" phase
SEARCHED_MIN_DURATION_MS = 1000 // "Reading" phase
```
### Fetch Renderer Timing
```typescript
READING_MIN_DURATION_MS = 1000
```
### Reasoning Renderer Timing
```typescript
THINKING_MIN_DURATION_MS = 500
```
### Animation Timing
```typescript
PACKET_DELAY_MS = 10 // Between packet reveals
```
### Timing Flow
```
Tool Group Visible
|
Record start time (toolStartTimesRef)
|
Tool completes (handleToolComplete called)
|
Calculate elapsed = now - startTime
|
elapsed >= 1500ms?
+-- Yes: Mark complete immediately
+-- No: setTimeout(markComplete, 1500 - elapsed)
|
All parallel tools complete?
+-- Yes: Show next turn's tools
+-- No: Wait for remaining tools
```
---
## 13. Citations and Documents
### Citation Processing
**During Streaming:**
```typescript
if (packet.obj.type === PacketType.CITATION_INFO) {
citationMapRef.current[citation_number] = document_id;
if (!seenCitationDocIdsRef.current.has(document_id)) {
seenCitationDocIdsRef.current.add(document_id);
citationsRef.current.push({ citation_num, document_id });
}
}
```
**Effective Citations:**
```typescript
const effectiveChatState = {
...chatState,
citations: {
...chatState.citations, // Props citations
...streamingCitationMap, // Streaming (takes precedence)
},
};
```
### Document Sources
1. **SEARCH_TOOL_DOCUMENTS_DELTA:**
```typescript
docDelta.documents.forEach(doc => {
documentMapRef.current.set(doc.document_id, doc);
});
```
2. **FETCH_TOOL_DOCUMENTS:**
```typescript
fetchDocuments.documents.forEach(doc => {
documentMapRef.current.set(doc.document_id, doc);
});
```
3. **MESSAGE_START.final_documents:**
- Available in chatState.docs
- Not extracted in packet loop
### CitedSourcesToggle Display
```typescript
{nodeId && (citations.length > 0 || documentMap.size > 0) && (
<CitedSourcesToggle
citations={citations}
documentMap={documentMap}
nodeId={nodeId}
onToggle={...}
/>
)}
```
### Document Sidebar Control
```typescript
onToggle={(toggledNodeId) => {
if (selectedMessageForDocDisplay === toggledNodeId && documentSidebarVisible) {
// Close sidebar
updateCurrentDocumentSidebarVisible(false);
updateCurrentSelectedNodeForDocDisplay(null);
} else {
// Open/switch sidebar
updateCurrentSelectedNodeForDocDisplay(toggledNodeId);
updateCurrentDocumentSidebarVisible(true);
}
}}
```
---
## Verification Checklist
After refactoring, verify:
- [ ] Streaming messages render incrementally
- [ ] Tools display in correct order with timing
- [ ] Parallel tools show together with tabs
- [ ] Internal search shows two-step process
- [ ] Citations appear as clickable links
- [ ] Document sidebar toggles correctly
- [ ] Feedback buttons work (like/dislike/toggle)
- [ ] Copy preserves markdown formatting
- [ ] Message switching works between alternatives
- [ ] Regeneration with model override works
- [ ] User cancellation shows X icon, stops shimmer
- [ ] Tool errors show red X, "Completed with errors"
- [ ] Research agents show nested tools correctly
- [ ] Deep research plans auto-collapse
- [ ] All minimum display times respected
- [ ] State resets on nodeId change
- [ ] No duplicate onComplete calls

View File

@@ -0,0 +1,224 @@
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/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";
// 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;
}
// 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
// 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,
}: 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,
uniqueToolNames,
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}
uniqueToolNames={uniqueToolNames}
/>
{/* Row 2: Display content + MessageToolbar */}
<div
ref={markdownRef}
className="overflow-x-visible focus:outline-none select-text cursor-text px-3"
onCopy={(e) => {
if (markdownRef.current) {
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
}
}}
>
{displayGroups.length > 0 && (
<div ref={finalAnswerRef}>
{displayGroups.map((displayGroup, index) => (
<RendererComponent
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
packets={displayGroup.packets}
chatState={effectiveChatState}
onComplete={() => {
// Only mark complete on the last display group
// Hook handles the finalAnswerComing check internally
if (index === displayGroups.length - 1) {
onRenderComplete();
}
}}
animate={false}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{({ content }) => <div>{content}</div>}
</RendererComponent>
))}
{/* Show stopped message when user cancelled and no display content */}
{displayGroups.length === 0 &&
stopReason === StopReason.USER_CANCELLED && (
<Text as="p" secondaryBody text04>
User has stopped generation
</Text>
)}
</div>
)}
</div>
{/* Feedback buttons - only show when streaming and rendering complete */}
{isComplete && (
<MessageToolbar
nodeId={nodeId}
messageId={messageId}
includeMessageSwitcher={includeMessageSwitcher}
currentMessageInd={currentMessageInd}
otherMessagesCanSwitchTo={otherMessagesCanSwitchTo}
getPreviousMessage={getPreviousMessage}
getNextMessage={getNextMessage}
onMessageSelection={onMessageSelection}
rawPackets={rawPackets}
finalAnswerRef={finalAnswerRef}
currentFeedback={currentFeedback}
onRegenerate={onRegenerate}
parentMessage={parentMessage}
llmManager={llmManager}
currentModelName={chatState.overriddenModel}
citations={citations}
documentMap={documentMap}
/>
)}
</div>
);
}, arePropsEqual);
export default AgentMessage;

View File

@@ -4,10 +4,9 @@ import { SourceIcon } from "@/components/SourceIcon";
import { WebResultIcon } from "@/components/WebResultIcon";
import { OnyxDocument } from "@/lib/search/interfaces";
import { ValidSources } from "@/lib/types";
import { IconProps } from "@/components/icons/icons";
import Tag from "@/refresh-components/buttons/Tag";
const SIZE = 14;
interface SourcesToggleProps {
citations: Array<{
citation_num: number;
@@ -30,42 +29,10 @@ export default function CitedSourcesToggle({
return null;
}
// Helper function to create icon for a document
const createDocumentIcon = (doc: OnyxDocument, documentId: string) => {
let sourceKey: string;
let iconElement: React.ReactNode;
if (doc.is_internet || doc.source_type === ValidSources.Web) {
// For web sources, use the hostname as the unique key
try {
const hostname = new URL(doc.link).hostname;
sourceKey = `web_${hostname}`;
} catch {
sourceKey = `web_${doc.link}`;
}
iconElement = (
<WebResultIcon key={documentId} url={doc.link} size={SIZE} />
);
} else {
sourceKey = `source_${doc.source_type}`;
iconElement = (
<SourceIcon
key={documentId}
sourceType={doc.source_type}
iconSize={SIZE}
/>
);
}
return { sourceKey, iconElement };
};
// Get unique icons by creating a unique identifier for each source
const getUniqueIcons = () => {
// Get unique icon factory functions
const getIconFactories = (): React.FunctionComponent<IconProps>[] => {
const seenSources = new Set<string>();
const uniqueIcons: Array<{
id: string;
element: React.ReactNode;
}> = [];
const factories: React.FunctionComponent<IconProps>[] = [];
// Get documents to process - either from citations or fallback to all documents
const documentsToProcess =
@@ -80,38 +47,51 @@ export default function CitedSourcesToggle({
}));
for (const { documentId, doc } of documentsToProcess) {
if (uniqueIcons.length >= 2) break;
if (factories.length >= 2) break;
let sourceKey: string;
let iconElement: React.ReactNode;
let iconFactory: React.FunctionComponent<IconProps>;
if (doc) {
const iconData = createDocumentIcon(doc, documentId);
sourceKey = iconData.sourceKey;
iconElement = iconData.iconElement;
if (doc.is_internet || doc.source_type === ValidSources.Web) {
// For web sources, use the hostname as the unique key
try {
const hostname = new URL(doc.link).hostname;
sourceKey = `web_${hostname}`;
} catch {
sourceKey = `web_${doc.link}`;
}
const url = doc.link;
iconFactory = (props: IconProps) => (
<WebResultIcon url={url} size={props.size} />
);
} else {
sourceKey = `source_${doc.source_type}`;
const sourceType = doc.source_type;
iconFactory = (props: IconProps) => (
<SourceIcon sourceType={sourceType} iconSize={props.size ?? 10} />
);
}
} else {
// Fallback for missing document (only possible with citations)
sourceKey = `file_${documentId}`;
iconElement = <FiFileText key={documentId} size={SIZE} />;
iconFactory = (props: IconProps) => (
<FiFileText size={props.size} className={props.className} />
);
}
if (!seenSources.has(sourceKey)) {
seenSources.add(sourceKey);
uniqueIcons.push({
id: sourceKey,
element: iconElement,
});
factories.push(iconFactory);
}
}
return uniqueIcons;
return factories;
};
const uniqueIcons = getUniqueIcons();
return (
<Tag label="Sources" onClick={() => onToggle(nodeId)}>
{uniqueIcons.map((icon) => (() => icon.element) as any)}
{getIconFactories()}
</Tag>
);
}

View File

@@ -0,0 +1,274 @@
import React, { RefObject, useState, useCallback } 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 CitedSourcesToggle from "@/app/chat/message/messageComponents/CitedSourcesToggle";
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";
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) && (
<CitedSourcesToggle
citations={citations}
documentMap={documentMap}
nodeId={nodeId}
onToggle={(toggledNodeId) => {
// Toggle sidebar if clicking on the same message
if (
selectedMessageForDocDisplay === toggledNodeId &&
documentSidebarVisible
) {
updateCurrentDocumentSidebarVisible(false);
updateCurrentSelectedNodeForDocDisplay(null);
} else {
updateCurrentSelectedNodeForDocDisplay(toggledNodeId);
updateCurrentDocumentSidebarVisible(true);
}
}}
/>
)}
</div>
</TooltipGroup>
</div>
</>
);
}

View File

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

View File

@@ -0,0 +1,439 @@
import {
Packet,
PacketType,
StreamingCitation,
StopReason,
CitationInfo,
SearchToolDocumentsDelta,
FetchToolDocuments,
TopLevelBranching,
Stop,
SearchToolStart,
CustomToolStart,
} 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>;
// Unique tool names tracking (populated during packet processing)
uniqueToolNames: Set<string>;
// Streaming status
finalAnswerComing: boolean;
stopPacketSeen: boolean;
stopReason: StopReason | undefined;
// Result arrays (built at end of processPackets)
toolGroups: GroupedPacket[];
potentialDisplayGroups: GroupedPacket[];
uniqueToolNamesArray: string[];
}
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(),
uniqueToolNames: new Set(),
finalAnswerComing: false,
stopPacketSeen: false,
stopReason: undefined,
toolGroups: [],
potentialDisplayGroups: [],
uniqueToolNamesArray: [],
};
}
// ============================================================================
// 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)
);
}
/**
* Extract tool name from a packet for unique tool tracking.
* Returns null for non-tool packets.
*/
function getToolNameFromPacket(packet: Packet): string | null {
switch (packet.obj.type) {
case PacketType.SEARCH_TOOL_START: {
const searchPacket = packet.obj as SearchToolStart;
return searchPacket.is_internet_search ? "Web Search" : "Internal Search";
}
case PacketType.PYTHON_TOOL_START:
return "Code Interpreter";
case PacketType.FETCH_TOOL_START:
return "Open URLs";
case PacketType.CUSTOM_TOOL_START: {
const customPacket = packet.obj as CustomToolStart;
return customPacket.tool_name || "Custom Tool";
}
case PacketType.IMAGE_GENERATION_TOOL_START:
return "Generate Image";
case PacketType.DEEP_RESEARCH_PLAN_START:
return "Generate plan";
case PacketType.RESEARCH_AGENT_START:
return "Research agent";
case PacketType.REASONING_START:
return "Thinking";
default:
return null;
}
}
/**
* Packet types that indicate final answer content is coming
*/
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);
// Track unique tool name
const toolName = getToolNameFromPacket(packet);
if (toolName) {
state.uniqueToolNames.add(toolName);
}
}
if (isDisplayPacket(packet)) {
state.displayGroupKeys.add(groupKey);
}
}
// 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);
}
// 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;
// Build result arrays after processing
state.toolGroups = buildGroupsFromKeys(state, state.toolGroupKeys);
state.potentialDisplayGroups = buildGroupsFromKeys(
state,
state.displayGroupKeys
);
state.uniqueToolNamesArray = Array.from(state.uniqueToolNames);
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.
*/
function buildGroupsFromKeys(
state: ProcessorState,
keys: Set<string>
): GroupedPacket[] {
return Array.from(keys)
.map((key) => {
const { turn_index, tab_index } = parseToolKey(key);
const packets = state.groupedPacketsMap.get(key);
// Spread to create new array reference - ensures React detects changes for re-renders
return packets ? { turn_index, tab_index, packets: [...packets] } : null;
})
.filter(
(g): g is GroupedPacket => g !== null && hasContentPackets(g.packets)
)
.sort((a, b) => {
if (a.turn_index !== b.turn_index) {
return a.turn_index - b.turn_index;
}
return a.tab_index - b.tab_index;
});
}

View File

@@ -17,10 +17,10 @@ import { ImageToolRenderer } from "./renderers/ImageToolRenderer";
import { PythonToolRenderer } from "./renderers/PythonToolRenderer";
import { ReasoningRenderer } from "./renderers/ReasoningRenderer";
import CustomToolRenderer from "./renderers/CustomToolRenderer";
import { FetchToolRenderer } from "./renderers/FetchToolRenderer";
import { FetchToolRenderer } from "./timeline/fetch/FetchToolRenderer";
import { DeepResearchPlanRenderer } from "./renderers/DeepResearchPlanRenderer";
import { ResearchAgentRenderer } from "./renderers/ResearchAgentRenderer";
import { SearchToolRenderer } from "./renderers/SearchToolRenderer";
import { SearchToolRenderer } from "./timeline/search/SearchToolRenderer";
// Different types of chat packets using discriminated unions
export interface GroupedPackets {

View File

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

View File

@@ -1,7 +1,5 @@
import React, { useMemo } from "react";
import React, { useMemo, useCallback } from "react";
import { FiList } from "react-icons/fi";
import { SvgChevronDown } from "@opal/icons";
import { cn } from "@/lib/utils";
import {
DeepResearchPlanPacket,
@@ -9,7 +7,9 @@ import {
} from "../../../services/streamingModels";
import { MessageRenderer, FullChatState } from "../interfaces";
import { usePacketAnimationAndCollapse } from "../hooks/usePacketAnimationAndCollapse";
import { useMarkdownRenderer } from "../markdownUtils";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ExpandableTextDisplay from "@/refresh-components/ExpandableTextDisplay";
import { mutedTextMarkdownComponents } from "./sharedMarkdownComponents";
/**
* Renderer for deep research plan packets.
@@ -31,31 +31,33 @@ export const DeepResearchPlanRenderer: MessageRenderer<
// Check if plan generation is complete (has SECTION_END)
const isComplete = packets.some((p) => p.obj.type === PacketType.SECTION_END);
// Use shared hook for animation and auto-collapse logic
const { displayedPacketCount, isExpanded, toggleExpanded } =
usePacketAnimationAndCollapse({
packets,
animate,
isComplete,
onComplete,
});
// Use shared hook for animation logic (collapse behavior no longer needed)
const { displayedPacketCount } = usePacketAnimationAndCollapse({
packets,
animate,
isComplete,
onComplete,
});
// Get the full content from all packets
const fullContent = packets
.map((packet) => {
if (packet.obj.type === PacketType.DEEP_RESEARCH_PLAN_DELTA) {
return packet.obj.content;
}
return "";
})
.join("");
const fullContent = useMemo(
() =>
packets
.map((packet) => {
if (packet.obj.type === PacketType.DEEP_RESEARCH_PLAN_DELTA) {
return packet.obj.content;
}
return "";
})
.join(""),
[packets]
);
// Get content based on displayed packet count
const content = useMemo(() => {
// Animated content for collapsed view (respects streaming animation)
const animatedContent = useMemo(() => {
if (!animate || displayedPacketCount === -1) {
return fullContent;
}
return packets
.slice(0, displayedPacketCount)
.map((packet) => {
@@ -67,49 +69,32 @@ export const DeepResearchPlanRenderer: MessageRenderer<
.join("");
}, [animate, displayedPacketCount, fullContent, packets]);
// Use markdown renderer to render the plan content
const { renderedContent } = useMarkdownRenderer(
content,
state,
"text-text-03 font-main-ui-body"
// Markdown renderer callback for ExpandableTextDisplay
const renderMarkdown = useCallback(
(text: string) => (
<MinimalMarkdown
content={text}
components={mutedTextMarkdownComponents}
/>
),
[]
);
const statusText = isComplete ? "Generated plan" : "Generating plan";
const statusElement = (
<div
className="flex items-center justify-between gap-2 cursor-pointer group w-full"
onClick={toggleExpanded}
>
<span>{statusText}</span>
<div className="flex items-center gap-2">
<SvgChevronDown
className={cn(
"w-4 h-4 stroke-text-400 transition-transform duration-150 ease-in-out",
!isExpanded && "rotate-[-90deg]"
)}
/>
</div>
</div>
);
const planContent = (
<div className="text-text-600 text-sm overflow-hidden">
{/* Collapsible content */}
<div
className={cn(
"overflow-hidden transition-all duration-200 ease-in-out",
isExpanded ? "max-h-[2000px] opacity-100 mt-2" : "max-h-0 opacity-0"
)}
>
{renderedContent}
</div>
</div>
<ExpandableTextDisplay
title="Deep research plan"
content={fullContent}
displayContent={animatedContent}
maxLines={5}
renderContent={renderMarkdown}
/>
);
return children({
icon: FiList,
status: statusElement,
status: statusText,
content: planContent,
expandedText: planContent,
});

View File

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

View File

@@ -13,7 +13,8 @@ import {
import { CodeBlock } from "@/app/chat/message/CodeBlock";
import hljs from "highlight.js/lib/core";
import python from "highlight.js/lib/languages/python";
import { SvgCode } from "@opal/icons";
import { SvgTerminal } from "@opal/icons";
import FadeDiv from "@/components/FadeDiv";
// Register Python language for highlighting
hljs.registerLanguage("python", python);
@@ -91,112 +92,23 @@ export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
}, [isComplete, onComplete]);
const status = useMemo(() => {
if (isComplete) {
if (hasError) {
return "Python execution failed";
}
return "Python execution completed";
}
if (isExecuting) {
return "Executing Python code...";
}
return null;
if (hasError) {
return "Python execution failed";
}
if (isComplete) {
return "Python execution completed";
}
return "Python execution";
}, [isComplete, isExecuting, hasError]);
// Render based on renderType
if (renderType === RenderType.FULL) {
// Loading state - when executing
if (isExecuting) {
return children({
icon: SvgCode,
status: "Executing Python code...",
content: (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-0.5">
<div className="w-1 h-1 bg-current rounded-full animate-pulse"></div>
<div
className="w-1 h-1 bg-current rounded-full animate-pulse"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="w-1 h-1 bg-current rounded-full animate-pulse"
style={{ animationDelay: "0.2s" }}
></div>
</div>
<span>Running code...</span>
</div>
),
});
}
// Complete state - show output
if (isComplete) {
return children({
icon: SvgCode,
status: hasError
? "Python execution failed"
: "Python execution completed",
content: (
<div className="flex flex-col my-1 space-y-2">
{code && (
<div className="prose max-w-full">
{/* NOTE: note that we need to trim since otherwise there's a huge
"space" at the start of the code block */}
<CodeBlock className="language-python" codeText={code.trim()}>
<HighlightedPythonCode code={code.trim()} />
</CodeBlock>
</div>
)}
{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>
)}
{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>
)}
{fileIds.length > 0 && (
<div className="text-sm text-gray-600 dark:text-gray-400">
Generated {fileIds.length} file{fileIds.length !== 1 ? "s" : ""}
</div>
)}
{!stdout && !stderr && (
<div className="py-2 text-center text-gray-500 dark:text-gray-400">
<SvgCode className="w-4 h-4 mx-auto mb-1 opacity-50" />
<p className="text-xs">No output</p>
</div>
)}
</div>
),
});
}
// Fallback
return children({
icon: SvgCode,
status: status,
content: <div></div>,
});
}
// Highlight/Short rendering
if (isExecuting) {
return children({
icon: SvgCode,
status: "Executing Python code...",
content: (
// 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>
@@ -211,43 +123,77 @@ export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
</div>
<span>Running code...</span>
</div>
),
});
}
)}
if (hasError) {
return children({
icon: SvgCode,
status: "Python execution failed",
content: (
<div className="text-sm text-red-600 dark:text-red-400">
Execution failed
{/* Code block */}
{code && (
<div className="prose max-w-full">
<CodeBlock className="language-python" codeText={code.trim()}>
<HighlightedPythonCode code={code.trim()} />
</CodeBlock>
</div>
),
});
}
)}
if (isComplete) {
return children({
icon: SvgCode,
status: "Python execution completed",
content: (
<div className="text-sm text-muted-foreground">
Execution completed
{fileIds.length > 0 &&
` - ${fileIds.length} file${
fileIds.length !== 1 ? "s" : ""
} generated`}
{/* 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: SvgCode,
status: "Python execution",
icon: SvgTerminal,
status,
supportsCompact: true,
content: (
<div className="text-sm text-muted-foreground">Python execution</div>
<FadeDiv direction="bottom" height={80}>
{content}
</FadeDiv>
),
});
};

View File

@@ -1,4 +1,10 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
PacketType,
@@ -6,7 +12,10 @@ import {
ReasoningPacket,
} from "../../../services/streamingModels";
import { MessageRenderer, FullChatState } from "../interfaces";
import { useMarkdownRenderer } from "../markdownUtils";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ExpandableTextDisplay from "@/refresh-components/ExpandableTextDisplay";
import { mutedTextMarkdownComponents } from "./sharedMarkdownComponents";
import { SvgCircle } from "@opal/icons";
const THINKING_MIN_DURATION_MS = 500; // 0.5 second minimum for "Thinking" state
@@ -39,7 +48,7 @@ function constructCurrentReasoningState(packets: ReasoningPacket[]) {
export const ReasoningRenderer: MessageRenderer<
ReasoningPacket,
FullChatState
> = ({ packets, state, onComplete, animate, children }) => {
> = ({ packets, onComplete, animate, children }) => {
const { hasStart, hasEnd, content } = useMemo(
() => constructCurrentReasoningState(packets),
[packets]
@@ -92,21 +101,36 @@ export const ReasoningRenderer: MessageRenderer<
};
}, []);
const { renderedContent } = useMarkdownRenderer(
content,
state,
"text-text-03 font-main-ui-body"
// Markdown renderer callback for ExpandableTextDisplay
const renderMarkdown = useCallback(
(text: string) => (
<MinimalMarkdown
content={text}
components={mutedTextMarkdownComponents}
/>
),
[]
);
if (!hasStart && !hasEnd && content.length === 0) {
return children({ icon: null, status: null, content: <></> });
return children({ icon: SvgCircle, status: null, content: <></> });
}
const reasoningContent = (
<ExpandableTextDisplay
title="Thinking"
content={content}
displayContent={content}
maxLines={5}
renderContent={renderMarkdown}
/>
);
return children({
icon: null,
icon: SvgCircle,
status: THINKING_STATUS,
content: renderedContent,
expandedText: renderedContent,
content: reasoningContent,
expandedText: reasoningContent,
});
};

View File

@@ -1,7 +1,13 @@
import React, { useEffect, useMemo, useState, useRef } from "react";
import { FiUsers, FiCircle, FiTarget } from "react-icons/fi";
import { SvgChevronDown } from "@opal/icons";
import { cn } from "@/lib/utils";
import React, {
useEffect,
useMemo,
useRef,
useCallback,
FunctionComponent,
} from "react";
import { FiTarget } from "react-icons/fi";
import { SvgCircle, SvgCheckCircle } from "@opal/icons";
import { IconProps } from "@opal/types";
import {
PacketType,
@@ -10,11 +16,14 @@ import {
ResearchAgentStart,
IntermediateReportDelta,
} from "../../../services/streamingModels";
import { MessageRenderer, FullChatState, RendererResult } from "../interfaces";
import { RendererComponent } from "../renderMessageComponent";
import { MessageRenderer, FullChatState } from "../interfaces";
import { getToolName } from "../toolDisplayHelpers";
import { STANDARD_TEXT_COLOR } from "../constants";
import Text from "@/refresh-components/texts/Text";
import { StepContainer } from "../timeline/StepContainer";
import {
TimelineRendererComponent,
TimelineRendererResult,
} from "../timeline/TimelineRendererComponent";
import ExpandableTextDisplay from "@/refresh-components/ExpandableTextDisplay";
import { useMarkdownRenderer } from "../markdownUtils";
interface NestedToolGroup {
@@ -25,76 +34,9 @@ interface NestedToolGroup {
packets: Packet[];
}
/**
* Simple row component for rendering nested tool content
*/
function NestedToolItemRow({
icon,
content,
status,
isLastItem,
isLoading,
isCancelled,
}: {
icon: ((props: { size: number }) => React.JSX.Element) | null;
content: React.JSX.Element | string;
status: string | React.JSX.Element | null;
isLastItem: boolean;
isLoading?: boolean;
isCancelled?: boolean;
}) {
return (
<div className="relative">
{!isLastItem && (
<div
className="absolute w-px bg-background-tint-04 z-0"
style={{ left: "10px", top: "20px", bottom: "0" }}
/>
)}
<div
className={cn(
"flex items-start gap-2",
STANDARD_TEXT_COLOR,
"relative z-10"
)}
>
<div className="flex flex-col items-center w-5">
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 bg-background rounded-full">
{icon ? (
<div className={cn(isLoading && "text-shimmer-base")}>
{icon({ size: 14 })}
</div>
) : (
<FiCircle className="w-2 h-2 fill-current text-text-300" />
)}
</div>
</div>
<div
className={cn(
"flex-1 min-w-0 overflow-hidden",
!isLastItem && "pb-4"
)}
>
<Text
as="p"
text02
className={cn(
"text-sm mb-1",
isLoading && !isCancelled && "loading-text"
)}
>
{status}
</Text>
<div className="text-sm text-text-600 overflow-hidden">{content}</div>
</div>
</div>
</div>
);
}
/**
* Renderer for research agent steps in deep research.
* Shows the research task, nested tool calls, and streams the intermediate report.
* Segregates packets by tool and uses StepContainer + TimelineRendererComponent.
*/
export const ResearchAgentRenderer: MessageRenderer<
ResearchAgentPacket,
@@ -103,9 +45,8 @@ export const ResearchAgentRenderer: MessageRenderer<
packets,
state,
onComplete,
renderType,
animate,
stopPacketSeen,
isLastStep = true,
children,
}) => {
// Extract the research task from the start packet
@@ -116,8 +57,7 @@ export const ResearchAgentRenderer: MessageRenderer<
? (startPacket.obj as ResearchAgentStart).research_task
: "";
// Separate parent packets (no sub_turn_index or sub_turn_index === undefined)
// from nested tool packets (sub_turn_index is a number)
// Separate parent packets from nested tool packets
const { parentPackets, nestedToolGroups } = useMemo(() => {
const parent: Packet[] = [];
const nestedBySubTurn = new Map<number, Packet[]>();
@@ -125,10 +65,8 @@ export const ResearchAgentRenderer: MessageRenderer<
packets.forEach((packet) => {
const subTurnIndex = packet.placement.sub_turn_index;
if (subTurnIndex === undefined || subTurnIndex === null) {
// Parent-level packet (research agent start, intermediate report, etc.)
parent.push(packet);
} else {
// Nested tool packet
if (!nestedBySubTurn.has(subTurnIndex)) {
nestedBySubTurn.set(subTurnIndex, []);
}
@@ -141,7 +79,6 @@ export const ResearchAgentRenderer: MessageRenderer<
.sort(([a], [b]) => a - b)
.map(([subTurnIndex, toolPackets]) => {
const name = getToolName(toolPackets);
// Check for completion: SECTION_END for regular tools, REASONING_DONE for reasoning/think tools
const isComplete = toolPackets.some(
(p) =>
p.obj.type === PacketType.SECTION_END ||
@@ -159,20 +96,12 @@ export const ResearchAgentRenderer: MessageRenderer<
return { parentPackets: parent, nestedToolGroups: groups };
}, [packets]);
// Check if report has started (from parent packets only)
const hasReportStarted = parentPackets.some(
(p) => p.obj.type === PacketType.INTERMEDIATE_REPORT_START
);
// Check if complete - research agent is complete when parent packets have SECTION_END
// (not when nested tools have SECTION_END)
// Check completion from parent packets
const isComplete = parentPackets.some(
(p) => p.obj.type === PacketType.SECTION_END
);
const [isExpanded, toggleExpanded] = useState(true);
const hasCalledCompleteRef = useRef(false);
// Call onComplete when research agent is complete
useEffect(() => {
if (isComplete && !hasCalledCompleteRef.current) {
hasCalledCompleteRef.current = true;
@@ -180,7 +109,7 @@ export const ResearchAgentRenderer: MessageRenderer<
}
}, [isComplete, onComplete]);
// Get the full report content from parent packets only
// Build report content from parent packets
const fullReportContent = parentPackets
.map((packet) => {
if (packet.obj.type === PacketType.INTERMEDIATE_REPORT_DELTA) {
@@ -190,142 +119,102 @@ export const ResearchAgentRenderer: MessageRenderer<
})
.join("");
const reportContent = fullReportContent;
// Use markdown renderer to render the report content
const { renderedContent: renderedReportContent } = useMarkdownRenderer(
reportContent,
// Markdown renderer for ExpandableTextDisplay
const { renderedContent } = useMarkdownRenderer(
fullReportContent,
state,
"text-text-03 font-main-ui-body"
);
// Determine status text
let statusText: string;
if (isComplete) {
statusText = "Research complete";
} else if (hasReportStarted) {
statusText = "Writing report";
} else if (nestedToolGroups.length > 0) {
const activeTools = nestedToolGroups.filter((g) => !g.isComplete);
if (activeTools.length > 0) {
statusText =
activeTools[activeTools.length - 1]?.toolType ?? "Processing";
} else {
statusText = "Processing";
}
} else {
statusText = "Researching";
}
// Render nested tool using RendererComponent for full detailed output
const renderNestedTool = (
group: NestedToolGroup,
index: number,
totalGroups: number
) => {
const isLastItem = index === totalGroups - 1;
// If stopPacketSeen is true, loading is false (cancelled state)
const isLoading = !stopPacketSeen && !group.isComplete && !isComplete;
// Tool is cancelled if stop was triggered and it's not complete
const isCancelled = stopPacketSeen && !group.isComplete;
return (
<RendererComponent
key={group.sub_turn_index}
packets={group.packets}
chatState={state}
onComplete={() => {}}
animate={false}
stopPacketSeen={stopPacketSeen || false}
useShortRenderer={false}
>
{(result: RendererResult) => (
<NestedToolItemRow
icon={result.icon}
content={result.content}
status={result.status}
isLastItem={isLastItem}
isLoading={isLoading}
isCancelled={isCancelled}
/>
)}
</RendererComponent>
);
};
// Total steps = research task (1) + nested tool groups count
const stepCount = 1 + nestedToolGroups.length;
// Custom status element with toggle chevron
const statusElement = (
<div
className="flex items-center justify-between gap-2 cursor-pointer group w-full"
onClick={() => toggleExpanded(!isExpanded)}
>
<span>{statusText}</span>
<div className="flex items-center gap-2">
{stepCount > 0 && (
<span className="text-text-500 text-xs">{stepCount} Steps</span>
)}
<SvgChevronDown
className={cn(
"w-4 h-4 stroke-text-400 transition-transform duration-150 ease-in-out",
!isExpanded && "rotate-[-90deg]"
)}
/>
</div>
</div>
);
// Stable callbacks to avoid creating new functions on every render
const noopComplete = useCallback(() => {}, []);
const renderReport = useCallback(() => renderedContent, [renderedContent]);
// Build content using StepContainer pattern
const researchAgentContent = (
<div className="text-text-600 text-sm overflow-hidden">
{/* Collapsible content */}
<div
className={cn(
"overflow-hidden transition-all duration-200 ease-in-out",
isExpanded ? "max-h-[2000px] opacity-100 mt-2" : "max-h-0 opacity-0"
)}
>
{/* First item: Research Task */}
{researchTask && (
<div className="space-y-0.5 mb-1">
<NestedToolItemRow
icon={({ size }) => <FiTarget size={size} />}
content={
<div className="text-text-600 text-sm break-words whitespace-normal">
{researchTask}
</div>
}
status="Research Task"
isLastItem={nestedToolGroups.length === 0 && !reportContent}
isLoading={false}
/>
</div>
)}
<div className="flex flex-col">
{/* Research Task - using StepContainer (collapsible) */}
{researchTask && (
<StepContainer
stepIcon={FiTarget as FunctionComponent<IconProps>}
header="Research Task"
collapsible={true}
isLastStep={
nestedToolGroups.length === 0 && !fullReportContent && !isComplete
}
>
<div className="text-text-600 text-sm">{researchTask}</div>
</StepContainer>
)}
{/* Render nested tool calls */}
{nestedToolGroups.length > 0 && (
<div className="space-y-0.5">
{nestedToolGroups.map((group, index) =>
renderNestedTool(group, index, nestedToolGroups.length)
{/* Nested tool calls - using TimelineRendererComponent + StepContainer */}
{nestedToolGroups.map((group, index) => {
const isLastNestedStep =
index === nestedToolGroups.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}
>
{({ icon, status, content, isExpanded, onToggle }) => (
<StepContainer
stepIcon={icon as FunctionComponent<IconProps> | undefined}
header={status}
isExpanded={isExpanded}
onToggle={onToggle}
collapsible={true}
isLastStep={isLastNestedStep}
isFirstStep={!researchTask && index === 0}
>
{content}
</StepContainer>
)}
</div>
)}
</TimelineRendererComponent>
);
})}
{/* Render intermediate report */}
{reportContent && (
<div className="mt-6 text-sm text-text-500 max-h-[9rem] overflow-y-auto">
{renderedReportContent}
</div>
)}
</div>
{/* Intermediate report - using ExpandableTextDisplay */}
{fullReportContent && (
<StepContainer
stepIcon={SvgCircle as FunctionComponent<IconProps>}
header="Research Report"
isLastStep={!isComplete}
isFirstStep={!researchTask && nestedToolGroups.length === 0}
>
<ExpandableTextDisplay
title="Research Report"
content={fullReportContent}
maxLines={5}
renderContent={renderReport}
/>
</StepContainer>
)}
{/* Done indicator at end of research agent */}
{isComplete && !isLastStep && (
<StepContainer
stepIcon={SvgCheckCircle}
header="Done"
isLastStep={isLastStep}
isFirstStep={false}
/>
)}
</div>
);
// Return simplified result (no icon, no status)
return children({
icon: FiUsers,
status: statusElement,
icon: null,
status: null,
content: researchAgentContent,
expandedText: researchAgentContent,
});
};

View File

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

View File

@@ -0,0 +1,434 @@
"use client";
import React, { FunctionComponent, useMemo, useCallback } from "react";
import { StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState } 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 { useTimelineHeader } from "./useTimelineHeader";
import { ParallelTimelineTabs } from "./ParallelTimelineTabs";
import { StepContainer } from "./StepContainer";
import { useTimelineExpansion, useTimelineMetrics } from "./hooks";
import { isResearchAgentPackets, stepSupportsCompact } from "./utils";
import {
StreamingHeader,
CollapsedHeader,
ExpandedHeader,
StoppedHeader,
ParallelStreamingHeader,
} from "./headers";
// =============================================================================
// TimelineStep Component - Memoized to prevent re-renders
// =============================================================================
interface TimelineStepProps {
step: TransformedStep;
chatState: FullChatState;
stopPacketSeen: boolean;
stopReason?: StopReason;
isLastStep: boolean;
isFirstStep: boolean;
isSingleStep: boolean;
}
const noopCallback = () => {};
const TimelineStep = React.memo(function TimelineStep({
step,
chatState,
stopPacketSeen,
stopReason,
isLastStep,
isFirstStep,
isSingleStep,
}: TimelineStepProps) {
// Stable render callback - doesn't need to change between renders
const renderStep = useCallback(
({
icon,
status,
content,
isExpanded,
onToggle,
isLastStep: rendererIsLastStep,
supportsCompact,
}: TimelineRendererResult) =>
isResearchAgentPackets(step.packets) ? (
content
) : (
<StepContainer
stepIcon={icon as FunctionComponent<IconProps> | undefined}
header={status}
isExpanded={isExpanded}
onToggle={onToggle}
collapsible={true}
supportsCompact={supportsCompact}
isLastStep={rendererIsLastStep}
isFirstStep={isFirstStep}
hideHeader={isSingleStep}
>
{content}
</StepContainer>
),
[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>
);
});
// =============================================================================
// 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;
/** Unique tool names (pre-computed for performance) */
uniqueToolNames?: string[];
}
export function AgentTimeline({
turnGroups,
chatState,
stopPacketSeen = false,
stopReason,
finalAnswerComing = false,
hasDisplayContent = false,
collapsible = true,
buttonTitle,
className,
"data-testid": testId,
uniqueToolNames = [],
}: AgentTimelineProps) {
// Header text and state flags
const { headerText, hasPackets, userStopped } = useTimelineHeader(
turnGroups,
stopReason
);
// Memoized metrics derived from turn groups
const {
totalSteps,
isSingleStep,
uniqueTools,
lastTurnGroup,
lastStep,
lastStepIsResearchAgent,
lastStepSupportsCompact,
} = useTimelineMetrics(turnGroups, uniqueToolNames, userStopped);
// Expansion state management
const { isExpanded, handleToggle, parallelActiveTab, setParallelActiveTab } =
useTimelineExpansion(stopPacketSeen, lastTurnGroup);
// 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) &&
!isResearchAgentPackets(parallelActiveStep.packets)
);
}, [parallelActiveStep]);
// Collapsed streaming: show compact content below header
const showCollapsedCompact =
!stopPacketSeen &&
!isExpanded &&
lastStep &&
!lastTurnGroup?.isParallel &&
!lastStepIsResearchAgent &&
lastStepSupportsCompact;
// Parallel tabs in header only when collapsed (expanded view has tabs in content)
const showParallelTabs =
!stopPacketSeen &&
!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
const renderHeader = () => {
if (!stopPacketSeen) {
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}
/>
);
}
if (userStopped) {
return (
<StoppedHeader
totalSteps={totalSteps}
collapsible={collapsible}
isExpanded={isExpanded}
onToggle={handleToggle}
/>
);
}
if (!isExpanded) {
return (
<CollapsedHeader
uniqueTools={uniqueTools}
totalSteps={totalSteps}
collapsible={collapsible}
onToggle={handleToggle}
/>
);
}
return <ExpandedHeader collapsible={collapsible} onToggle={handleToggle} />;
};
// Empty state: no packets, still streaming
if (!hasPackets && !hasDisplayContent) {
return (
<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={chatState.assistant} size={24} />
</div>
<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>
</div>
</div>
);
}
// Display content only (no timeline steps)
if (hasDisplayContent && !hasPackets) {
return (
<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={chatState.assistant} size={24} />
</div>
</div>
</div>
);
}
return (
<div className={cn("flex flex-col", className)}>
{/* Header row */}
<div className="flex w-full h-9">
<div className="flex justify-center items-center size-9">
<AgentAvatar agent={chatState.assistant} size={24} />
</div>
<div
className={cn(
"flex w-full h-full items-center justify-between px-2",
(!stopPacketSeen || userStopped || isExpanded) &&
"bg-background-tint-00 rounded-t-12",
!isExpanded &&
!showCollapsedCompact &&
!showCollapsedParallel &&
"rounded-b-12"
)}
>
{renderHeader()}
</div>
</div>
{/* Collapsed streaming view - single step compact mode */}
{showCollapsedCompact && lastStep && (
<div className="flex w-full">
<div className="w-9" />
<div className="w-full 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}
isLastStep={true}
>
{renderContentOnly}
</TimelineRendererComponent>
</div>
</div>
)}
{/* Collapsed streaming view - parallel tools compact mode */}
{showCollapsedParallel && parallelActiveStep && (
<div className="flex w-full">
<div className="w-9" />
<div className="w-full 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}
isLastStep={true}
>
{renderContentOnly}
</TimelineRendererComponent>
</div>
</div>
)}
{/* 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="Done"
isLastStep={true}
isFirstStep={false}
>
{null}
</StepContainer>
)}
{/* Stopped indicator */}
{stopPacketSeen && isExpanded && userStopped && (
<StepContainer
stepIcon={SvgStopCircle}
header="Stopped"
isLastStep={true}
isFirstStep={false}
>
{null}
</StepContainer>
)}
</div>
)}
</div>
);
}
export default AgentTimeline;

View File

@@ -0,0 +1,130 @@
"use client";
import React, {
useState,
useMemo,
useCallback,
FunctionComponent,
} from "react";
import { StopReason } from "@/app/chat/services/streamingModels";
import { FullChatState } from "../interfaces";
import { TurnGroup } from "./transformers";
import { getToolName, getToolIcon } from "../toolDisplayHelpers";
import {
TimelineRendererComponent,
TimelineRendererResult,
} from "./TimelineRendererComponent";
import Tabs from "@/refresh-components/Tabs";
import { SvgBranch } from "@opal/icons";
import { StepContainer } from "./StepContainer";
import { isResearchAgentPackets } from "./utils";
import { IconProps } from "@/components/icons/icons";
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 ?? "");
// Find the active step based on selected tab
const activeStep = useMemo(
() => turnGroup.steps.find((step) => step.key === activeTab),
[turnGroup.steps, activeTab]
);
// Stable callbacks to avoid creating new functions on every render
const noopComplete = useCallback(() => {}, []);
const renderTabContent = useCallback(
({
icon,
status,
content,
isExpanded,
onToggle,
isLastStep,
}: TimelineRendererResult) =>
isResearchAgentPackets(activeStep?.packets ?? []) ? (
content
) : (
<StepContainer
stepIcon={icon as FunctionComponent<IconProps> | undefined}
header={status}
isExpanded={isExpanded}
onToggle={onToggle}
collapsible={true}
isLastStep={isLastStep}
isFirstStep={false}
>
{content}
</StepContainer>
),
[activeStep?.packets]
);
return (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full">
{/* Left column: Icon + connector line */}
<div className="flex flex-col items-center w-9 pt-2">
<div className="size-4 flex items-center justify-center stroke-text-02">
<SvgBranch className="w-4 h-4" />
</div>
{/* Connector line */}
<div className="w-px flex-1 bg-border-01" />
</div>
{/* Right column: Tabs */}
<div className="flex-1">
<Tabs.List variant="pill">
{turnGroup.steps.map((step) => (
<Tabs.Trigger key={step.key} value={step.key} variant="pill">
<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}
packets={activeStep?.packets ?? []}
chatState={chatState}
onComplete={noopComplete}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
defaultExpanded={true}
isLastStep={isLastTurnGroup}
>
{renderTabContent}
</TimelineRendererComponent>
</div>
</div>
</Tabs>
);
}
export default ParallelTimelineTabs;

View File

@@ -0,0 +1,108 @@
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;
}
/** 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,
}: StepContainerProps) {
const showCollapseControls = collapsible && supportsCompact && onToggle;
return (
<div className={cn("flex w-full", className)}>
<div
className={cn("flex flex-col items-center w-9", isFirstStep && "pt-2")}
>
{/* Icon */}
{!hideHeader && StepIconComponent && (
<div className="py-1">
<StepIconComponent className="size-4 stroke-text-02" />
</div>
)}
{/* Connector line */}
{!isLastStep && <div className="w-px flex-1 bg-border-01" />}
</div>
<div
className={cn(
"w-full bg-background-tint-00",
isLastStep && "rounded-b-12"
)}
>
{!hideHeader && (
<div className="flex items-center justify-between px-2">
{header && (
<Text as="p" mainUiMuted text03>
{header}
</Text>
)}
{showCollapseControls &&
(buttonTitle ? (
<Button
tertiary
onClick={onToggle}
rightIcon={isExpanded ? SvgFold : SvgExpand}
>
{buttonTitle}
</Button>
) : (
<IconButton
tertiary
onClick={onToggle}
icon={isExpanded ? SvgFold : SvgExpand}
/>
))}
</div>
)}
<div className="px-2 pb-2">{children}</div>
</div>
</div>
);
}
export default StepContainer;

View File

@@ -0,0 +1,116 @@
"use client";
import React, { useState, 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;
}
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;
/** 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.defaultExpanded === next.defaultExpanded
// Skipping chatState (memoized upstream), onComplete (stable callback), children (render prop)
);
}
export const TimelineRendererComponent = React.memo(
function TimelineRendererComponent({
packets,
chatState,
onComplete,
animate,
stopPacketSeen,
stopReason,
defaultExpanded = true,
isLastStep,
children,
}: TimelineRendererComponentProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const handleToggle = () => setIsExpanded((prev) => !prev);
const RendererFn = findRenderer({ packets });
const renderType = isExpanded ? RenderType.FULL : RenderType.COMPACT;
if (!RendererFn) {
return children({
icon: null,
status: null,
content: <></>,
supportsCompact: false,
isExpanded,
onToggle: handleToggle,
renderType,
isLastStep: isLastStep ?? true,
});
}
return (
<RendererFn
packets={packets as any}
state={chatState}
onComplete={onComplete}
animate={animate}
renderType={renderType}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
isLastStep={isLastStep}
>
{({ icon, status, content, expandedText, supportsCompact }) =>
children({
icon,
status,
content,
expandedText,
supportsCompact,
isExpanded,
onToggle: handleToggle,
renderType,
isLastStep: isLastStep ?? true,
})
}
</RendererFn>
);
},
arePropsEqual
);

View File

@@ -0,0 +1,147 @@
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 { useToolTiming, getMetadataTags } from "../search";
import {
constructCurrentFetchState,
INITIAL_URLS_TO_SHOW,
URLS_PER_EXPANSION,
READING_MIN_DURATION_MS,
READ_MIN_DURATION_MS,
} from "./fetchStateUtils";
import Text from "@/refresh-components/texts/Text";
const urlToSourceInfo = (url: string, index: number): SourceInfo => ({
id: `url-${index}`,
title: url,
sourceType: "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),
},
});
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;
useToolTiming({
hasStarted: isLoading || isComplete,
isComplete,
animate,
stopPacketSeen,
onComplete,
activeDurationMs: READING_MIN_DURATION_MS,
completeDurationMs: READ_MIN_DURATION_MS,
});
if (!hasStarted) {
return children({
icon: FiLink,
status: null,
content: <div />,
supportsCompact: true,
});
}
const displayDocuments = documents.length > 0;
const displayUrls = !displayDocuments && isComplete && urls.length > 0;
return children({
icon: FiLink,
status: "Opening URLs:",
supportsCompact: true,
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={<BlinkingDot />}
/>
) : 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={<BlinkingDot />}
/>
) : (
<div className="flex flex-wrap gap-x-2 gap-y-2 ml-1">
<BlinkingDot />
</div>
))}
{(displayDocuments || displayUrls) && (
<>
{!isCompact && (
<Text as="p" mainUiMuted text03>
Reading results:
</Text>
)}
{displayDocuments ? (
<SearchChipList
items={documents}
initialCount={INITIAL_URLS_TO_SHOW}
expansionCount={URLS_PER_EXPANSION}
getKey={(doc: OnyxDocument) => `reading-${doc.document_id}`}
toSourceInfo={(doc: OnyxDocument) => documentToSourceInfo(doc)}
onClick={(doc: OnyxDocument) => {
if (doc.link) window.open(doc.link, "_blank");
}}
emptyState={<BlinkingDot />}
/>
) : (
<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={<BlinkingDot />}
/>
)}
</>
)}
</div>
),
});
};

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import React from "react";
import { SvgExpand } from "@opal/icons";
import Button from "@/refresh-components/buttons/Button";
import Text from "@/refresh-components/texts/Text";
import type { UniqueTool } from "../hooks";
export interface CollapsedHeaderProps {
uniqueTools: UniqueTool[];
totalSteps: number;
collapsible: boolean;
onToggle: () => void;
}
/** Header when completed + collapsed - tools summary + step count */
export const CollapsedHeader = React.memo(function CollapsedHeader({
uniqueTools,
totalSteps,
collapsible,
onToggle,
}: CollapsedHeaderProps) {
return (
<>
<div className="flex items-center gap-2">
{uniqueTools.map((tool) => (
<div
key={tool.key}
className="inline-flex items-center gap-1 rounded-08 p-1 bg-background-tint-02"
>
{tool.icon}
<Text as="span" secondaryBody text04>
{tool.name}
</Text>
</div>
))}
</div>
{collapsible && (
<Button
tertiary
onClick={onToggle}
rightIcon={SvgExpand}
aria-label="Expand timeline"
aria-expanded={false}
>
{totalSteps} {totalSteps === 1 ? "step" : "steps"}
</Button>
)}
</>
);
});

View File

@@ -0,0 +1,32 @@
import React from "react";
import { SvgFold } from "@opal/icons";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
export interface ExpandedHeaderProps {
collapsible: boolean;
onToggle: () => void;
}
/** Header when completed + expanded */
export const ExpandedHeader = React.memo(function ExpandedHeader({
collapsible,
onToggle,
}: ExpandedHeaderProps) {
return (
<>
<Text as="p" mainUiAction text03>
Thought for some time
</Text>
{collapsible && (
<IconButton
tertiary
onClick={onToggle}
icon={SvgFold}
aria-label="Collapse timeline"
aria-expanded={true}
/>
)}
</>
);
});

View File

@@ -0,0 +1,53 @@
import React 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 } 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) {
return (
<Tabs value={activeTab} onValueChange={onTabChange}>
<div className="flex items-center justify-between w-full gap-2">
<Tabs.List variant="pill">
{steps.map((step) => (
<Tabs.Trigger key={step.key} value={step.key} variant="pill">
<span className="flex items-center gap-1.5">
{getToolIcon(step.packets)}
{getToolName(step.packets)}
</span>
</Tabs.Trigger>
))}
</Tabs.List>
{collapsible && (
<IconButton
tertiary
onClick={onToggle}
icon={isExpanded ? SvgFold : SvgExpand}
aria-label={isExpanded ? "Collapse timeline" : "Expand timeline"}
aria-expanded={isExpanded}
/>
)}
</div>
</Tabs>
);
}
);

View File

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

View File

@@ -0,0 +1,54 @@
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";
export interface StreamingHeaderProps {
headerText: string;
collapsible: boolean;
buttonTitle?: string;
isExpanded: boolean;
onToggle: () => void;
}
/** Header during streaming - shimmer text with current activity */
export const StreamingHeader = React.memo(function StreamingHeader({
headerText,
collapsible,
buttonTitle,
isExpanded,
onToggle,
}: StreamingHeaderProps) {
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>
) : (
<IconButton
tertiary
onClick={onToggle}
icon={isExpanded ? SvgFold : SvgExpand}
aria-label={isExpanded ? "Collapse timeline" : "Expand timeline"}
aria-expanded={isExpanded}
/>
))}
</>
);
});

View File

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

View File

@@ -0,0 +1,5 @@
export { useTimelineExpansion } from "./useTimelineExpansion";
export type { TimelineExpansionState } from "./useTimelineExpansion";
export { useTimelineMetrics } from "./useTimelineMetrics";
export type { TimelineMetrics, UniqueTool } from "./useTimelineMetrics";

View File

@@ -0,0 +1,50 @@
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 and syncs parallel tab selection.
*/
export function useTimelineExpansion(
stopPacketSeen: boolean,
lastTurnGroup: TurnGroup | undefined
): TimelineExpansionState {
const [isExpanded, setIsExpanded] = useState(!stopPacketSeen);
const [parallelActiveTab, setParallelActiveTab] = useState<string>("");
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
// Auto-collapse when streaming completes
useEffect(() => {
if (stopPacketSeen) {
setIsExpanded(false);
}
}, [stopPacketSeen]);
// Sync active tab when parallel turn group changes
useEffect(() => {
if (lastTurnGroup?.isParallel && lastTurnGroup.steps.length > 0) {
const validTabs = lastTurnGroup.steps.map((s) => s.key);
const firstStep = lastTurnGroup.steps[0];
if (firstStep && !validTabs.includes(parallelActiveTab)) {
setParallelActiveTab(firstStep.key);
}
}
}, [lastTurnGroup, parallelActiveTab]);
return {
isExpanded,
handleToggle,
parallelActiveTab,
setParallelActiveTab,
};
}

View File

@@ -0,0 +1,63 @@
import { useMemo } from "react";
import { TurnGroup, TransformedStep } from "../transformers";
import { getToolIconByName } from "../../toolDisplayHelpers";
import { isResearchAgentPackets, stepSupportsCompact } from "../utils";
export interface UniqueTool {
key: string;
name: string;
icon: React.JSX.Element;
}
export interface TimelineMetrics {
totalSteps: number;
isSingleStep: boolean;
uniqueTools: UniqueTool[];
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[],
uniqueToolNames: string[],
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,
uniqueTools: uniqueToolNames.map((name) => ({
key: name,
name,
icon: getToolIconByName(name),
})),
lastTurnGroup,
lastStep,
lastStepIsResearchAgent,
lastStepSupportsCompact,
};
}, [turnGroups, uniqueToolNames, userStopped]);
}

View File

@@ -0,0 +1,30 @@
export { AgentTimeline, type AgentTimelineProps } from "./AgentTimeline";
export { StepContainer, type StepContainerProps } from "./StepContainer";
export {
TimelineRendererComponent,
type TimelineRendererComponentProps,
type TimelineRendererResult,
} from "./TimelineRendererComponent";
export {
transformPacketGroup,
transformPacketGroups,
groupStepsByTurn,
type TransformedStep,
type TurnGroup,
} from "./transformers";
// Re-export hooks
export { useTimelineExpansion, useTimelineMetrics } from "./hooks";
export type {
TimelineExpansionState,
TimelineMetrics,
UniqueTool,
} from "./hooks";
// Re-export utils
export {
COMPACT_SUPPORTED_PACKET_TYPES,
isResearchAgentPackets,
stepSupportsCompact,
} from "./utils";

View File

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

View File

@@ -0,0 +1,122 @@
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 { useToolTiming } from "./useToolTiming";
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),
},
});
export const SearchToolRenderer: MessageRenderer<SearchToolPacket, {}> = ({
packets,
onComplete,
animate,
stopPacketSeen,
renderType,
children,
}) => {
const searchState = constructCurrentSearchState(packets);
const { queries, results, isSearching, isComplete, isInternetSearch } =
searchState;
const hasStarted = isSearching || isComplete;
const isCompact = renderType === RenderType.COMPACT;
useToolTiming({
hasStarted,
isComplete,
animate,
stopPacketSeen,
onComplete,
});
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,
});
}
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={<BlinkingDot />}
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={<BlinkingDot />}
/>
</>
)}
</div>
),
});
};

View File

@@ -0,0 +1,24 @@
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 {
useToolTiming,
type UseToolTimingOptions,
type UseToolTimingResult,
} from "./useToolTiming";
export {
SearchChipList,
type SearchChipListProps,
type SourceInfo,
} from "./SearchChipList";

View File

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

View File

@@ -0,0 +1,140 @@
import { useState, useEffect, useRef, useCallback } from "react";
const DEFAULT_ACTIVE_DURATION_MS = 1000;
const DEFAULT_COMPLETE_DURATION_MS = 1000;
export interface UseToolTimingOptions {
/** Whether the tool has started (even if it completed instantly) */
hasStarted: boolean;
/** Whether the tool is complete */
isComplete: boolean;
/** Whether to animate transitions (affects minimum durations) */
animate: boolean;
/** Whether a stop packet was received */
stopPacketSeen: boolean;
/** Callback when the timing sequence is complete */
onComplete: () => void;
/** Minimum duration for the "active" state in ms (default: 1000) */
activeDurationMs?: number;
/** Minimum duration for the "complete" state in ms (default: 1000) */
completeDurationMs?: number;
}
export interface UseToolTimingResult {
/** Whether to show the "active" state (e.g., "Searching", "Reading") */
shouldShowAsActive: boolean;
/** Whether to show the "complete" state (e.g., "Searched", "Read") */
shouldShowAsComplete: boolean;
}
/**
* Generic hook that manages timing state machine for tool display.
*
* Ensures minimum display durations for "active" and "complete" states
* to provide a smooth user experience even when tools complete quickly.
*
* State transitions:
* - Initial -> Active (when tool starts)
* - Active -> Complete (after min duration, when tool completes)
* - Complete -> Done (after min duration, calls onComplete)
*
* When stopped/cancelled, skips intermediate states and completes immediately.
*/
export function useToolTiming({
hasStarted,
isComplete,
animate,
stopPacketSeen,
onComplete,
activeDurationMs = DEFAULT_ACTIVE_DURATION_MS,
completeDurationMs = DEFAULT_COMPLETE_DURATION_MS,
}: UseToolTimingOptions): UseToolTimingResult {
const [startTime, setStartTime] = useState<number | null>(null);
const [shouldShowAsActive, setShouldShowAsActive] = useState(false);
const [shouldShowAsComplete, setShouldShowAsComplete] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const completeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const completionHandledRef = useRef(false);
const clearAllTimeouts = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (completeTimeoutRef.current) {
clearTimeout(completeTimeoutRef.current);
completeTimeoutRef.current = null;
}
}, []);
useEffect(() => {
if (hasStarted && startTime === null) {
setStartTime(Date.now());
setShouldShowAsActive(true);
}
}, [hasStarted, startTime]);
useEffect(() => {
if (isComplete && startTime !== null && !completionHandledRef.current) {
completionHandledRef.current = true;
if (stopPacketSeen) {
clearAllTimeouts();
setShouldShowAsActive(false);
setShouldShowAsComplete(false);
onComplete();
return;
}
const elapsedTime = Date.now() - startTime;
const minimumActiveDuration = animate ? activeDurationMs : 0;
const minimumCompleteDuration = animate ? completeDurationMs : 0;
const handleActiveToComplete = () => {
setShouldShowAsActive(false);
setShouldShowAsComplete(true);
completeTimeoutRef.current = setTimeout(() => {
setShouldShowAsComplete(false);
onComplete();
}, minimumCompleteDuration);
};
if (elapsedTime >= minimumActiveDuration) {
handleActiveToComplete();
} else {
const remainingTime = minimumActiveDuration - elapsedTime;
timeoutRef.current = setTimeout(handleActiveToComplete, remainingTime);
}
}
}, [
isComplete,
startTime,
animate,
stopPacketSeen,
onComplete,
clearAllTimeouts,
activeDurationMs,
completeDurationMs,
]);
useEffect(() => {
if (stopPacketSeen) {
clearAllTimeouts();
setShouldShowAsActive(false);
setShouldShowAsComplete(false);
}
}, [stopPacketSeen, clearAllTimeouts]);
useEffect(() => {
return () => {
clearAllTimeouts();
};
}, [clearAllTimeouts]);
return {
shouldShowAsActive,
shouldShowAsComplete,
};
}

View File

@@ -0,0 +1,78 @@
import { GroupedPacket } from "../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
*/
export function groupStepsByTurn(steps: TransformedStep[]): TurnGroup[] {
const turnMap = new Map<number, TransformedStep[]>();
for (const step of steps) {
const existing = turnMap.get(step.turnIndex);
if (existing) {
existing.push(step);
} else {
turnMap.set(step.turnIndex, [step]);
}
}
const result: TurnGroup[] = [];
const sortedTurnIndices = Array.from(turnMap.keys()).sort((a, b) => a - b);
for (const turnIndex of sortedTurnIndices) {
const stepsForTurn = turnMap.get(turnIndex)!;
stepsForTurn.sort((a, b) => a.tabIndex - b.tabIndex);
result.push({
turnIndex,
steps: stepsForTurn,
isParallel: stepsForTurn.length > 1,
});
}
return result;
}

View File

@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { TurnGroup } from "./transformers";
import {
PacketType,
SearchToolPacket,
StopReason,
CustomToolStart,
} from "@/app/chat/services/streamingModels";
import { constructCurrentSearchState } from "../renderers/SearchToolRenderer";
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
): TimelineHeaderResult {
return useMemo(() => {
const hasPackets = turnGroups.length > 0;
const userStopped = stopReason === StopReason.USER_CANCELLED;
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]);
}

View File

@@ -0,0 +1,5 @@
export {
COMPACT_SUPPORTED_PACKET_TYPES,
isResearchAgentPackets,
stepSupportsCompact,
} from "./packetHelpers";

View File

@@ -0,0 +1,19 @@
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,
]);
// 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 step supports compact rendering mode
export const stepSupportsCompact = (packets: Packet[]): boolean =>
packets.some((p) =>
COMPACT_SUPPORTED_PACKET_TYPES.has(p.obj.type as PacketType)
);

View File

@@ -142,3 +142,31 @@ export function getToolIcon(packets: Packet[]): JSX.Element {
return <FiCircle className="w-3.5 h-3.5" />;
}
}
/**
* Get tool icon by tool name string.
* Used when we have pre-computed tool names (e.g., from packet processor).
*/
export function getToolIconByName(name: string): JSX.Element {
switch (name) {
case "Web Search":
return <FiGlobe className="w-3.5 h-3.5" />;
case "Internal Search":
return <FiSearch className="w-3.5 h-3.5" />;
case "Code Interpreter":
return <FiCode className="w-3.5 h-3.5" />;
case "Open URLs":
return <FiLink className="w-3.5 h-3.5" />;
case "Generate Image":
return <FiImage className="w-3.5 h-3.5" />;
case "Generate plan":
return <FiList className="w-3.5 h-3.5" />;
case "Research agent":
return <FiUsers className="w-3.5 h-3.5" />;
case "Thinking":
return <BrainIcon className="w-3.5 h-3.5" />;
default:
// Custom tools or unknown
return <FiTool className="w-3.5 h-3.5" />;
}
}

View File

@@ -0,0 +1,153 @@
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 "./packetProcessor";
import {
transformPacketGroups,
groupStepsByTurn,
TurnGroup,
} from "./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>;
uniqueToolNames: string[];
// 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,
uniqueToolNames: state.uniqueToolNamesArray,
// Completion: stopPacketSeen && renderComplete
isComplete: state.stopPacketSeen && renderComplete,
// Callbacks
onRenderComplete,
markAllToolsDisplayed,
};
}

View File

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

View File

@@ -6,6 +6,8 @@ interface FadeDivProps {
fadeClassName?: string;
footerClassName?: string;
children: React.ReactNode;
direction?: "top" | "bottom";
height?: number | string;
}
const FadeDiv: React.FC<FadeDivProps> = ({
@@ -13,23 +15,48 @@ const FadeDiv: React.FC<FadeDivProps> = ({
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}
direction = "top",
height,
}) => {
const isBottom = direction === "bottom";
// Bottom direction: simple container with fade overlay
if (isBottom) {
return (
<div
className={cn("relative w-full overflow-hidden", className)}
style={height ? { maxHeight: height } : undefined}
>
{children}
<div
className={cn(
"absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent pointer-events-none",
fadeClassName
)}
/>
</div>
);
}
// Top direction: original behavior
return (
<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>
</div>
);
);
};
export default FadeDiv;

View File

@@ -9,6 +9,93 @@ 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 AgentMessage, {
RegenerationFactory,
} from "@/app/chat/message/messageComponents/AgentMessage";
import { FeedbackType } from "@/app/chat/interfaces";
import { FullChatState } from "@/app/chat/message/messageComponents/interfaces";
/**
* Memoized wrapper component for AgentMessage.
*
* WHY A SEPARATE COMPONENT (instead of useMemo inside MessageList.map):
* React hooks CANNOT be called inside loops or callbacks. This is invalid:
* messages.map((message) => {
* const chatState = useMemo(...); // INVALID - hooks can't be in map()
* return <AgentMessage chatState={chatState} />;
* });
*
* By extracting to a separate component, we CAN use hooks:
* - useMemo creates a stable chatState that only changes when dependencies change
* - React.memo() prevents re-renders when props are equal
* - AgentMessage receives stable props, so its arePropsEqual works correctly
*
* Without this wrapper, chatState was created inline in the map(), producing a
* NEW object on every render, which broke AgentMessage's memoization entirely.
*/
interface AgentMessageWrapperProps {
message: Message;
liveAssistant: MinimalPersonaSnapshot;
emptyDocs: OnyxDocument[];
setPresentingDocument: (doc: MinimalOnyxDocument | null) => void;
overriddenModel: string | undefined;
llmManager: LlmManager;
parentMessage: Message | null | undefined;
emptyChildrenIds: number[];
onMessageSelection: (nodeId: number) => void;
createRegenerator: RegenerationFactory;
}
const AgentMessageWrapper = React.memo(function AgentMessageWrapper({
message,
liveAssistant,
emptyDocs,
setPresentingDocument,
overriddenModel,
llmManager,
parentMessage,
emptyChildrenIds,
onMessageSelection,
createRegenerator,
}: AgentMessageWrapperProps) {
const chatState = useMemo<FullChatState>(
() => ({
assistant: liveAssistant,
docs: message.documents ?? emptyDocs,
citations: message.citations,
setPresentingDocument,
overriddenModel,
researchType: message.researchType,
}),
[
liveAssistant,
message.documents,
message.citations,
setPresentingDocument,
overriddenModel,
message.researchType,
emptyDocs,
]
);
return (
<AgentMessage
rawPackets={message.packets}
packetCount={message.packets.length}
chatState={chatState}
nodeId={message.nodeId}
messageId={message.messageId}
currentFeedback={message.currentFeedback}
llmManager={llmManager}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
onRegenerate={createRegenerator}
parentMessage={parentMessage}
/>
);
});
export interface MessageListProps {
messages: Message[];
@@ -164,14 +251,6 @@ const MessageList = React.memo(
}
const previousMessage = i !== 0 ? messages[i - 1] : null;
const chatStateData = {
assistant: liveAssistant,
docs: message.documents ?? emptyDocs,
citations: message.citations,
setPresentingDocument,
overriddenModel: llmManager.currentLlm?.modelName,
researchType: message.researchType,
};
return (
<div
@@ -179,19 +258,17 @@ const MessageList = React.memo(
key={messageReactComponentKey}
data-anchor={isAnchor ? "true" : undefined}
>
<AIMessage
rawPackets={message.packets}
chatState={chatStateData}
nodeId={message.nodeId}
messageId={message.messageId}
currentFeedback={message.currentFeedback}
<AgentMessageWrapper
message={message}
liveAssistant={liveAssistant}
emptyDocs={emptyDocs}
setPresentingDocument={setPresentingDocument}
overriddenModel={llmManager.currentLlm?.modelName}
llmManager={llmManager}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
parentMessage={parentMessage}
emptyChildrenIds={emptyChildrenIds}
onMessageSelection={onMessageSelection}
onRegenerate={createRegenerator}
parentMessage={previousMessage}
createRegenerator={createRegenerator}
/>
</div>
);

View File

@@ -13,6 +13,7 @@ import {
JSONIcon,
ImagesIcon,
XMLIcon,
IconProps,
} from "@/components/icons/icons";
export function getFileIconFromFileNameAndLink(
@@ -171,6 +172,43 @@ export function getUniqueIcons(docs: OnyxDocument[]): JSX.Element[] {
return uniqueIcons.slice(0, 3);
}
/**
* Returns unique icon factory functions for documents.
* Used with Tag component which expects FunctionComponent<IconProps>[].
*/
export function getUniqueIconFactories(
docs: OnyxDocument[]
): React.FunctionComponent<IconProps>[] {
const factories: React.FunctionComponent<IconProps>[] = [];
const seenDomains = new Set<string>();
const seenSourceTypes = new Set<ValidSources>();
for (const doc of docs) {
if (factories.length >= 3) break;
if ((doc.is_internet || doc.source_type === ValidSources.Web) && doc.link) {
const domain = getDomainFromUrl(doc.link);
if (domain && !seenDomains.has(domain)) {
seenDomains.add(domain);
const url = doc.link;
factories.push((props: IconProps) => (
<WebResultIcon url={url} size={props.size} />
));
}
} else {
if (!seenSourceTypes.has(doc.source_type)) {
seenSourceTypes.add(doc.source_type);
const sourceType = doc.source_type;
factories.push((props: IconProps) => (
<SourceIcon sourceType={sourceType} iconSize={props.size ?? 10} />
));
}
}
}
return factories;
}
export function SeeMoreBlock({
toggleDocumentSelection,
webSourceDomains,

View File

@@ -0,0 +1,183 @@
"use client";
import { useState, useMemo } 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 FadeDiv from "@/components/FadeDiv";
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. Default: 5 */
maxLines?: number;
/** 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;
}
/** Calculate content size in human-readable format */
function getContentSize(text: string): string {
const bytes = new Blob([text]).size;
if (bytes < 1024) return `${bytes} B`;
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);
}
}
export default function ExpandableTextDisplay({
title,
content,
displayContent,
subtitle,
maxLines = 5,
className,
renderContent,
}: ExpandableTextDisplayProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lineCount = useMemo(() => getLineCount(content), [content]);
const contentSize = useMemo(() => getContentSize(content), [content]);
const displaySubtitle = subtitle ?? contentSize;
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("relative w-full", className)}>
{/* Content with fade */}
<FadeDiv direction="bottom" fadeClassName="from-background-tint-00">
<div
className={cn(
lineClampClass,
!renderContent && "whitespace-pre-wrap"
)}
>
{renderContent ? (
renderContent(displayContent ?? content)
) : (
<Text as="p" mainUiMuted text03>
{displayContent ?? content}
</Text>
)}
</div>
</FadeDiv>
{/* Expand button */}
<div className="flex justify-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 expanded preventAccidentalClose={false}>
{/* Header */}
<div className="flex items-start justify-between px-4 py-3">
<div className="flex flex-col">
<DialogPrimitive.Title asChild>
<Text as="span" text04 headingH3>
{title}
</Text>
</DialogPrimitive.Title>
<DialogPrimitive.Description asChild>
<Text as="span" text03 secondaryBody>
{displaySubtitle}
</Text>
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close asChild>
<IconButton
icon={SvgX}
internal
onClick={() => setIsModalOpen(false)}
/>
</DialogPrimitive.Close>
</div>
{/* Body */}
<Modal.Body>
{renderContent ? (
renderContent(content)
) : (
<Text as="p" mainUiMuted text03 className="whitespace-pre-wrap">
{content}
</Text>
)}
</Modal.Body>
{/* Footer */}
<div className="flex items-center justify-between p-2 bg-background-tint-01">
<div className="px-2">
<Text as="span" mainUiMuted text03>
{lineCount} {lineCount === 1 ? "line" : "lines"}
</Text>
</div>
<div className="flex items-center gap-1 bg-background-tint-00 p-1 rounded-12">
<CopyIconButton
internal
getCopyText={() => content}
tooltip="Copy"
/>
<IconButton
internal
icon={SvgDownload}
tooltip="Download"
onClick={handleDownload}
/>
</div>
</div>
</Modal.Content>
</Modal>
</>
);
}

View File

@@ -59,6 +59,7 @@ interface ModalContextValue {
closeButtonRef: React.RefObject<HTMLDivElement | null>;
hasAttemptedClose: boolean;
setHasAttemptedClose: (value: boolean) => void;
sizeVariant: "large" | "medium" | "expanded" | "small" | "tall" | "mini";
height: keyof typeof heightClasses;
}
@@ -72,6 +73,17 @@ const useModalContext = () => {
return context;
};
/**
* Size class names mapping for modal variants
*/
const sizeClassNames = {
large: ["w-[80dvw]", "h-[80dvh]"],
medium: ["w-[60rem]", "h-fit"],
expanded: ["w-[40rem]", "max-h-[calc(100dvh-4rem)]"],
small: ["w-[32rem]", "max-h-[30rem]"],
tall: ["w-[32rem]", "max-h-[calc(100dvh-4rem)]"],
mini: ["w-[32rem]", "h-fit"],
} as const;
const widthClasses = {
lg: "w-[80dvw]",
md: "w-[60rem]",
@@ -114,6 +126,12 @@ interface ModalContentProps
extends WithoutStyles<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
> {
large?: boolean;
medium?: boolean;
expanded?: boolean;
small?: boolean;
tall?: boolean;
mini?: boolean;
width?: keyof typeof widthClasses;
height?: keyof typeof heightClasses;
preventAccidentalClose?: boolean;
@@ -126,6 +144,12 @@ const ModalContent = React.forwardRef<
(
{
children,
large,
medium,
expanded,
small,
tall,
mini,
width = "md",
height = "fit",
preventAccidentalClose = true,
@@ -134,6 +158,19 @@ const ModalContent = React.forwardRef<
},
ref
) => {
const variant = large
? "large"
: medium
? "medium"
: expanded
? "expanded"
: small
? "small"
: tall
? "tall"
: mini
? "mini"
: "medium";
const closeButtonRef = React.useRef<HTMLDivElement>(null);
const [hasAttemptedClose, setHasAttemptedClose] = React.useState(false);
const hasUserTypedRef = React.useRef(false);
@@ -249,6 +286,7 @@ const ModalContent = React.forwardRef<
closeButtonRef,
hasAttemptedClose,
setHasAttemptedClose,
sizeVariant: variant,
height,
}}
>

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useRef, useState, useEffect } from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
@@ -49,14 +49,33 @@ const TabsRoot = React.forwardRef<
));
TabsRoot.displayName = TabsPrimitive.Root.displayName;
/**
* Tabs List Props
*/
interface TabsListProps
extends WithoutStyles<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
> {
/** Visual variant of the tabs list
* - 'contained': Grid layout with background (default)
* - 'pill': Flex layout with bottom line indicator
*/
variant?: "contained" | "pill";
/** Content to render on the right side of the tab list (pill variant only) */
rightContent?: React.ReactNode;
}
/**
* 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.
*
* @param variant - Visual variant: 'contained' (default) or 'pill'
*
* @example
* ```tsx
* // Contained variant (default)
* <Tabs defaultValue="overview">
* <Tabs.List>
* <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
@@ -64,27 +83,127 @@ TabsRoot.displayName = TabsPrimitive.Root.displayName;
* <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>
*
* // Pill variant
* <Tabs defaultValue="search">
* <Tabs.List variant="pill">
* <Tabs.Trigger value="search" variant="pill">Search</Tabs.Trigger>
* <Tabs.Trigger value="browse" variant="pill">Browse</Tabs.Trigger>
* </Tabs.List>
* </Tabs>
* ```
*
* @remarks
* - Default styling: rounded pill background with padding
* - Height: 2.5rem (h-10)
* - Contained: rounded pill background with grid layout
* - Pill: transparent background with bottom line indicator
* - Supports keyboard navigation (Left/Right arrows, Home/End keys)
* - Custom className can be added for additional styling if needed
*/
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, children, ...props }, ref) => {
const internalRef = useRef<HTMLDivElement>(null);
const [indicatorStyle, setIndicatorStyle] = useState({
left: 0,
width: 0,
opacity: 0,
});
// Update indicator position when active tab changes (pill variant only)
useEffect(() => {
if (variant !== "pill") return;
const updateIndicator = () => {
const list = internalRef.current;
if (!list) return;
const activeTab = list.querySelector<HTMLElement>(
'[data-state="active"]'
);
if (activeTab) {
const listRect = list.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
setIndicatorStyle({
left: tabRect.left - listRect.left,
width: tabRect.width,
opacity: 1,
});
}
};
updateIndicator();
// Use MutationObserver to detect tab changes
const observer = new MutationObserver(updateIndicator);
if (internalRef.current) {
observer.observe(internalRef.current, {
attributes: true,
subtree: true,
attributeFilter: ["data-state"],
});
}
return () => observer.disconnect();
}, [variant]);
return (
<TabsPrimitive.List
ref={(node) => {
internalRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
className={cn(
// Contained variant (default)
variant === "contained" &&
"grid w-full rounded-08 bg-background-tint-03",
// Pill variant
variant === "pill" &&
"relative flex items-center pb-[4px] bg-background-tint-00"
)}
style={
variant === "contained"
? {
gridTemplateColumns: `repeat(${React.Children.count(
children
)}, 1fr)`,
}
: undefined
}
{...props}
>
{/* Tabs container */}
{variant === "pill" ? (
<div className="flex items-center gap-2">{children}</div>
) : (
children
)}
{/* Right action slot for pill variant */}
{variant === "pill" && rightContent && (
<div className="ml-auto pl-2">{rightContent}</div>
)}
{/* Full-width subtle line for pill variant */}
{variant === "pill" && (
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-02 pointer-events-none" />
)}
{/* Sliding active indicator for pill variant */}
{variant === "pill" && (
<div
className="absolute bottom-0 h-[2px] bg-background-tint-inverted-03 z-10 transition-all duration-200 ease-out pointer-events-none"
style={{
left: indicatorStyle.left,
width: indicatorStyle.width,
opacity: indicatorStyle.opacity,
}}
/>
)}
</TabsPrimitive.List>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
/**
@@ -97,13 +216,21 @@ interface TabsTriggerProps
"children"
>
> {
/** Visual variant of the tab trigger (should match parent TabsList variant)
* - 'contained': Background-based active state (default)
* - 'pill': Dark pill with bottom line indicator
*/
variant?: "contained" | "pill";
/** Optional tooltip text to display on hover */
tooltip?: string;
/** Side where tooltip appears. Default: "top" */
tooltipSide?: "top" | "bottom" | "left" | "right";
icon?: React.FunctionComponent<IconProps>;
children?: string;
/** Tab label - can be string or ReactNode (for custom content like icon + text) */
children?: React.ReactNode;
/** Show loading spinner after label */
isLoading?: boolean;
}
/**
@@ -156,13 +283,36 @@ const TabsTrigger = React.forwardRef<
TabsTriggerProps
>(
(
{ tooltip, tooltipSide = "top", icon: Icon, children, disabled, ...props },
{
variant = "contained",
tooltip,
tooltipSide = "top",
icon: Icon,
children,
disabled,
isLoading,
...props
},
ref
) => {
const inner = (
<>
{Icon && <Icon size={16} className="stroke-text-03" />}
<Text>{children}</Text>
{Icon && (
<Icon
size={14}
className={cn(
variant === "contained" && "stroke-text-03",
variant === "pill" && "stroke-current"
)}
/>
)}
{typeof children === "string" ? <Text>{children}</Text> : children}
{isLoading && (
<span
className="inline-block w-3 h-3 border-2 border-text-03 border-t-transparent rounded-full animate-spin"
aria-label="Loading"
/>
)}
</>
);
@@ -171,11 +321,22 @@ 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",
"inline-flex items-center justify-center whitespace-nowrap rounded-08",
// 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"
// Contained variant (default)
variant === "contained" && [
"p-2 gap-2",
"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",
],
// Pill variant - 12px text, smooth transitions
variant === "pill" && [
"p-1.5 font-secondary-action",
"transition-all duration-200 ease-out",
"data-[state=active]:bg-background-tint-inverted-03 data-[state=active]:text-text-inverted-05",
"data-[state=inactive]:bg-transparent data-[state=inactive]:text-text-03",
]
)}
{...props}
>

View File

@@ -0,0 +1,239 @@
"use client";
import React, { useState, useMemo } 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/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 === "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;
}
export default function SourceTag({
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;
// Get variant-specific styles
const size = inlineCitation ? "inlineCitation" : "tag";
const styles = sizeClasses[size];
const handlePrev = () => {
setCurrentIndex((prev) => Math.max(0, prev - 1));
};
const handleNext = () => {
setCurrentIndex((prev) => Math.min(sources.length - 1, prev + 1));
};
// Reset to first source when tooltip closes
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setCurrentIndex(0);
}
};
// Use hover state for non-tooltip mode
const isActive = isOpen || (!showDetailsCard && false); // isOpen handles tooltip, CSS handles non-tooltip hover
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 === "web" && source.sourceUrl ? (
<WebResultIcon url={source.sourceUrl} size={12} />
) : (
<SourceIcon
sourceType={
source.sourceType === "web"
? ValidSources.Web
: source.sourceType
}
iconSize={12}
/>
)}
</div>
))}
</div>
)}
{/* Text content */}
<div className={cn("flex items-baseline", !inlineCitation && "pr-0.5")}>
<Text
figureSmallValue={inlineCitation && !isOpen}
figureSmallLabel={inlineCitation && isOpen}
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>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import React from "react";
import Text from "@/refresh-components/texts/Text";
import IconButton from "@/refresh-components/buttons/IconButton";
import {
SvgArrowLeft,
SvgArrowRight,
SvgUser,
SvgHashSmall,
} 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";
export interface SourceInfo {
id: string;
title: string;
sourceType: ValidSources | "web";
sourceUrl?: string;
description?: string;
metadata?: {
author?: string;
date?: string | Date;
tags?: string[];
};
icon?: React.FunctionComponent<IconProps>;
}
interface SourceTagDetailsCardProps {
sources: SourceInfo[];
currentIndex: number;
onPrev: () => void;
onNext: () => void;
}
interface MetadataChipProps {
icon?: React.FunctionComponent<IconProps>;
text: string;
}
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>
);
}
export default function SourceTagDetailsCard({
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 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>
)}
{/* Content section */}
<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">
{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
as="span"
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 ">
{/* Tags */}
<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, index) => <MetadataChip key={index} text={tag} />)}
{/* Date */}
{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>
);
}

View File

@@ -3,7 +3,7 @@
import React from "react";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SVGProps } from "react";
import { IconProps } from "@/components/icons/icons";
const getVariantClasses = (active?: boolean) => [
active ? "bg-background-tint-00" : "bg-background-tint-01",
@@ -17,7 +17,7 @@ export interface TagProps {
// Tag content:
label: string;
className?: string;
children: React.FunctionComponent<SVGProps<SVGSVGElement>>[];
children: React.FunctionComponent<IconProps>[];
onClick?: React.MouseEventHandler;
}
@@ -50,7 +50,7 @@ export default function Tag({
className="relative bg-background-tint-00 border border-background-tint-01 p-0.5 rounded-04"
style={{ zIndex: visibleIcons.length - index }}
>
<Icon className="w-[0.6rem] h-[0.6rem] stroke-text-04" />
<Icon size={10} className="stroke-text-04" />
</div>
))}
</div>

View File

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