mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-07 07:52:44 +00:00
Compare commits
38 Commits
cli/v0.2.1
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac08fc28a | ||
|
|
60be22c63b | ||
|
|
5fbfd1c54f | ||
|
|
b3e03fcbab | ||
|
|
d79d52a9fe | ||
|
|
193cc6f1db | ||
|
|
55439c734d | ||
|
|
cea35d7d9c | ||
|
|
f8c2fc58a4 | ||
|
|
60fa837216 | ||
|
|
16c00fbb5f | ||
|
|
a7f12eeac1 | ||
|
|
328513877a | ||
|
|
92ca6f2830 | ||
|
|
b08570ef43 | ||
|
|
818fffdaa1 | ||
|
|
38084b676e | ||
|
|
e676915711 | ||
|
|
f9454cfa7f | ||
|
|
afdc581633 | ||
|
|
64e2771aad | ||
|
|
2ea63c0d67 | ||
|
|
c5f7db6566 | ||
|
|
6dbcdfb208 | ||
|
|
27c64c5cdb | ||
|
|
adf9f742b6 | ||
|
|
545866d151 | ||
|
|
324675842f | ||
|
|
3623bbe02d | ||
|
|
d0015120a1 | ||
|
|
380cdac456 | ||
|
|
d32dd726b6 | ||
|
|
b82e9e196f | ||
|
|
fea7021852 | ||
|
|
53777bd6e6 | ||
|
|
15d110aa7e | ||
|
|
85d6634899 | ||
|
|
55040dc23c |
@@ -572,7 +572,7 @@ def translate_assistant_message_to_packets(
|
||||
# Determine stop reason - check if message indicates user cancelled
|
||||
stop_reason: str | None = None
|
||||
if chat_message.message:
|
||||
if "Generation was stopped" in chat_message.message:
|
||||
if "generation was stopped" in chat_message.message.lower():
|
||||
stop_reason = "user_cancelled"
|
||||
|
||||
# Add overall stop packet at the end
|
||||
|
||||
21
web/lib/opal/src/icons/branch.tsx
Normal file
21
web/lib/opal/src/icons/branch.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgBranch = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.75001 5C5.71651 5 6.50001 4.2165 6.50001 3.25C6.50001 2.2835 5.7165 1.5 4.75 1.5C3.78351 1.5 3.00001 2.2835 3.00001 3.25C3.00001 4.2165 3.78351 5 4.75001 5ZM4.75001 5L4.75001 6.24999M4.75 11C3.7835 11 3 11.7835 3 12.75C3 13.7165 3.7835 14.5 4.75 14.5C5.7165 14.5 6.5 13.7165 6.5 12.75C6.5 11.7835 5.71649 11 4.75 11ZM4.75 11L4.75001 6.24999M10.5 8.74997C10.5 9.71646 11.2835 10.5 12.25 10.5C13.2165 10.5 14 9.71646 14 8.74997C14 7.78347 13.2165 7 12.25 7C11.2835 7 10.5 7.78347 10.5 8.74997ZM10.5 8.74997L7.25001 8.74999C5.8693 8.74999 4.75001 7.6307 4.75001 6.24999"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgBranch;
|
||||
16
web/lib/opal/src/icons/circle.tsx
Normal file
16
web/lib/opal/src/icons/circle.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgCircle = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" strokeWidth={1.5} />
|
||||
</svg>
|
||||
);
|
||||
export default SvgCircle;
|
||||
21
web/lib/opal/src/icons/download.tsx
Normal file
21
web/lib/opal/src/icons/download.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgDownload = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 10V12.6667C14 13.3929 13.3929 14 12.6667 14H3.33333C2.60711 14 2 13.3929 2 12.6667V10M4.66667 6.66667L8 10M8 10L11.3333 6.66667M8 10L8 2"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgDownload;
|
||||
@@ -24,6 +24,7 @@ export { default as SvgBookOpen } from "@opal/icons/book-open";
|
||||
export { default as SvgBooksLineSmall } from "@opal/icons/books-line-small";
|
||||
export { default as SvgBooksStackSmall } from "@opal/icons/books-stack-small";
|
||||
export { default as SvgBracketCurly } from "@opal/icons/bracket-curly";
|
||||
export { default as SvgBranch } from "@opal/icons/branch";
|
||||
export { default as SvgBubbleText } from "@opal/icons/bubble-text";
|
||||
export { default as SvgCalendar } from "@opal/icons/calendar";
|
||||
export { default as SvgCheck } from "@opal/icons/check";
|
||||
@@ -36,6 +37,7 @@ export { default as SvgChevronLeft } from "@opal/icons/chevron-left";
|
||||
export { default as SvgChevronRight } from "@opal/icons/chevron-right";
|
||||
export { default as SvgChevronUp } from "@opal/icons/chevron-up";
|
||||
export { default as SvgChevronUpSmall } from "@opal/icons/chevron-up-small";
|
||||
export { default as SvgCircle } from "@opal/icons/circle";
|
||||
export { default as SvgClaude } from "@opal/icons/claude";
|
||||
export { default as SvgClipboard } from "@opal/icons/clipboard";
|
||||
export { default as SvgClock } from "@opal/icons/clock";
|
||||
@@ -46,6 +48,7 @@ export { default as SvgCopy } from "@opal/icons/copy";
|
||||
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
|
||||
export { default as SvgCpu } from "@opal/icons/cpu";
|
||||
export { default as SvgDevKit } from "@opal/icons/dev-kit";
|
||||
export { default as SvgDownload } from "@opal/icons/download";
|
||||
export { default as SvgDownloadCloud } from "@opal/icons/download-cloud";
|
||||
export { default as SvgEdit } from "@opal/icons/edit";
|
||||
export { default as SvgEditBig } from "@opal/icons/edit-big";
|
||||
@@ -132,6 +135,7 @@ export { default as SvgStep3End } from "@opal/icons/step3-end";
|
||||
export { default as SvgStop } from "@opal/icons/stop";
|
||||
export { default as SvgStopCircle } from "@opal/icons/stop-circle";
|
||||
export { default as SvgSun } from "@opal/icons/sun";
|
||||
export { default as SvgTerminal } from "@opal/icons/terminal";
|
||||
export { default as SvgTerminalSmall } from "@opal/icons/terminal-small";
|
||||
export { default as SvgTextLinesSmall } from "@opal/icons/text-lines-small";
|
||||
export { default as SvgThumbsDown } from "@opal/icons/thumbs-down";
|
||||
|
||||
22
web/lib/opal/src/icons/terminal.tsx
Normal file
22
web/lib/opal/src/icons/terminal.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgTerminal = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2.66667 11.3333L6.66667 7.33331L2.66667 3.33331M8.00001 12.6666H13.3333"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgTerminal;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
987
web/src/app/chat/message/REQUIREMENTS.md
Normal file
987
web/src/app/chat/message/REQUIREMENTS.md
Normal 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
|
||||
224
web/src/app/chat/message/messageComponents/AgentMessage.tsx
Normal file
224
web/src/app/chat/message/messageComponents/AgentMessage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
274
web/src/app/chat/message/messageComponents/MessageToolbar.tsx
Normal file
274
web/src/app/chat/message/messageComponents/MessageToolbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
439
web/src/app/chat/message/messageComponents/packetProcessor.ts
Normal file
439
web/src/app/chat/message/messageComponents/packetProcessor.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Components } from "react-markdown";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
export const mutedTextMarkdownComponents = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<Text as="p" text03 mainUiMuted className="!my-1">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
li: ({ children }: { children?: React.ReactNode }) => (
|
||||
<Text as="li" text03 mainUiMuted className="!my-0 !py-0 leading-normal">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
} satisfies Partial<Components>;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PacketType,
|
||||
FetchToolPacket,
|
||||
FetchToolUrls,
|
||||
FetchToolDocuments,
|
||||
} from "@/app/chat/services/streamingModels";
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
|
||||
export const INITIAL_URLS_TO_SHOW = 3;
|
||||
export const URLS_PER_EXPANSION = 5;
|
||||
export const READING_MIN_DURATION_MS = 1000;
|
||||
export const READ_MIN_DURATION_MS = 1000;
|
||||
|
||||
export interface FetchState {
|
||||
urls: string[];
|
||||
documents: OnyxDocument[];
|
||||
hasStarted: boolean;
|
||||
isLoading: boolean;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
/** Constructs the current fetch state from fetch tool packets. */
|
||||
export const constructCurrentFetchState = (
|
||||
packets: FetchToolPacket[]
|
||||
): FetchState => {
|
||||
const startPacket = packets.find(
|
||||
(packet) => packet.obj.type === PacketType.FETCH_TOOL_START
|
||||
);
|
||||
const urlsPacket = packets.find(
|
||||
(packet) => packet.obj.type === PacketType.FETCH_TOOL_URLS
|
||||
)?.obj as FetchToolUrls | undefined;
|
||||
const documentsPacket = packets.find(
|
||||
(packet) => packet.obj.type === PacketType.FETCH_TOOL_DOCUMENTS
|
||||
)?.obj as FetchToolDocuments | undefined;
|
||||
const sectionEnd = packets.find(
|
||||
(packet) =>
|
||||
packet.obj.type === PacketType.SECTION_END ||
|
||||
packet.obj.type === PacketType.ERROR
|
||||
);
|
||||
|
||||
const urls = urlsPacket?.urls || [];
|
||||
const documents = documentsPacket?.documents || [];
|
||||
const hasStarted = Boolean(startPacket);
|
||||
const isLoading = hasStarted && !documentsPacket;
|
||||
const isComplete = Boolean(startPacket && sectionEnd);
|
||||
|
||||
return { urls, documents, hasStarted, isLoading, isComplete };
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export { FetchToolRenderer } from "./FetchToolRenderer";
|
||||
|
||||
export {
|
||||
constructCurrentFetchState,
|
||||
type FetchState,
|
||||
INITIAL_URLS_TO_SHOW,
|
||||
URLS_PER_EXPANSION,
|
||||
READING_MIN_DURATION_MS,
|
||||
READ_MIN_DURATION_MS,
|
||||
} from "./fetchStateUtils";
|
||||
@@ -0,0 +1,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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export { StreamingHeader } from "./StreamingHeader";
|
||||
export type { StreamingHeaderProps } from "./StreamingHeader";
|
||||
|
||||
export { CollapsedHeader } from "./CollapsedHeader";
|
||||
export type { CollapsedHeaderProps } from "./CollapsedHeader";
|
||||
|
||||
export { ExpandedHeader } from "./ExpandedHeader";
|
||||
export type { ExpandedHeaderProps } from "./ExpandedHeader";
|
||||
|
||||
export { StoppedHeader } from "./StoppedHeader";
|
||||
export type { StoppedHeaderProps } from "./StoppedHeader";
|
||||
|
||||
export { ParallelStreamingHeader } from "./ParallelStreamingHeader";
|
||||
export type { ParallelStreamingHeaderProps } from "./ParallelStreamingHeader";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useTimelineExpansion } from "./useTimelineExpansion";
|
||||
export type { TimelineExpansionState } from "./useTimelineExpansion";
|
||||
|
||||
export { useTimelineMetrics } from "./useTimelineMetrics";
|
||||
export type { TimelineMetrics, UniqueTool } from "./useTimelineMetrics";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
30
web/src/app/chat/message/messageComponents/timeline/index.ts
Normal file
30
web/src/app/chat/message/messageComponents/timeline/index.ts
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
PacketType,
|
||||
SearchToolPacket,
|
||||
SearchToolStart,
|
||||
SearchToolQueriesDelta,
|
||||
SearchToolDocumentsDelta,
|
||||
SectionEnd,
|
||||
} from "@/app/chat/services/streamingModels";
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
|
||||
export const MAX_TITLE_LENGTH = 25;
|
||||
|
||||
export const getMetadataTags = (metadata?: {
|
||||
[key: string]: string;
|
||||
}): string[] | undefined => {
|
||||
if (!metadata) return undefined;
|
||||
const tags = Object.values(metadata)
|
||||
.filter((value) => typeof value === "string" && value.length > 0)
|
||||
.slice(0, 2)
|
||||
.map((value) => `# ${value}`);
|
||||
return tags.length > 0 ? tags : undefined;
|
||||
};
|
||||
|
||||
export const INITIAL_QUERIES_TO_SHOW = 3;
|
||||
export const QUERIES_PER_EXPANSION = 5;
|
||||
export const INITIAL_RESULTS_TO_SHOW = 3;
|
||||
export const RESULTS_PER_EXPANSION = 10;
|
||||
|
||||
export interface SearchState {
|
||||
queries: string[];
|
||||
results: OnyxDocument[];
|
||||
isSearching: boolean;
|
||||
hasResults: boolean;
|
||||
isComplete: boolean;
|
||||
isInternetSearch: boolean;
|
||||
}
|
||||
|
||||
/** Constructs the current search state from search tool packets. */
|
||||
export const constructCurrentSearchState = (
|
||||
packets: SearchToolPacket[]
|
||||
): SearchState => {
|
||||
const searchStart = packets.find(
|
||||
(packet) => packet.obj.type === PacketType.SEARCH_TOOL_START
|
||||
)?.obj as SearchToolStart | null;
|
||||
|
||||
const queryDeltas = packets
|
||||
.filter(
|
||||
(packet) => packet.obj.type === PacketType.SEARCH_TOOL_QUERIES_DELTA
|
||||
)
|
||||
.map((packet) => packet.obj as SearchToolQueriesDelta);
|
||||
|
||||
const documentDeltas = packets
|
||||
.filter(
|
||||
(packet) => packet.obj.type === PacketType.SEARCH_TOOL_DOCUMENTS_DELTA
|
||||
)
|
||||
.map((packet) => packet.obj as SearchToolDocumentsDelta);
|
||||
|
||||
const searchEnd = packets.find(
|
||||
(packet) =>
|
||||
packet.obj.type === PacketType.SECTION_END ||
|
||||
packet.obj.type === PacketType.ERROR
|
||||
)?.obj as SectionEnd | null;
|
||||
|
||||
// Deduplicate queries using Set for O(n) instead of indexOf which is O(n²)
|
||||
const seenQueries = new Set<string>();
|
||||
const queries = queryDeltas
|
||||
.flatMap((delta) => delta?.queries || [])
|
||||
.filter((query) => {
|
||||
if (seenQueries.has(query)) return false;
|
||||
seenQueries.add(query);
|
||||
return true;
|
||||
});
|
||||
|
||||
const seenDocIds = new Set<string>();
|
||||
const results = documentDeltas
|
||||
.flatMap((delta) => delta?.documents || [])
|
||||
.filter((doc) => {
|
||||
if (!doc || !doc.document_id) return false;
|
||||
if (seenDocIds.has(doc.document_id)) return false;
|
||||
seenDocIds.add(doc.document_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const isSearching = Boolean(searchStart && !searchEnd);
|
||||
const hasResults = results.length > 0;
|
||||
const isComplete = Boolean(searchStart && searchEnd);
|
||||
const isInternetSearch = searchStart?.is_internet_search || false;
|
||||
|
||||
return {
|
||||
queries,
|
||||
results,
|
||||
isSearching,
|
||||
hasResults,
|
||||
isComplete,
|
||||
isInternetSearch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
COMPACT_SUPPORTED_PACKET_TYPES,
|
||||
isResearchAgentPackets,
|
||||
stepSupportsCompact,
|
||||
} from "./packetHelpers";
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
153
web/src/app/chat/message/messageComponents/usePacketProcessor.ts
Normal file
153
web/src/app/chat/message/messageComponents/usePacketProcessor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
183
web/src/refresh-components/ExpandableTextDisplay.tsx
Normal file
183
web/src/refresh-components/ExpandableTextDisplay.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
239
web/src/refresh-components/buttons/SourceTag.tsx
Normal file
239
web/src/refresh-components/buttons/SourceTag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
web/src/refresh-components/buttons/SourceTagDetailsCard.tsx
Normal file
175
web/src/refresh-components/buttons/SourceTagDetailsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,10 @@ module.exports = {
|
||||
spacing: "margin, padding",
|
||||
},
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
"0%": { backgroundPosition: "100% 0" },
|
||||
"100%": { backgroundPosition: "-100% 0" },
|
||||
},
|
||||
"subtle-pulse": {
|
||||
"0%, 100%": { opacity: 0.9 },
|
||||
"50%": { opacity: 0.5 },
|
||||
@@ -42,6 +46,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
shimmer: "shimmer 1.8s ease-out infinite",
|
||||
"fade-in-up": "fadeInUp 0.5s ease-out",
|
||||
"subtle-pulse": "subtle-pulse 2s ease-in-out infinite",
|
||||
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
|
||||
Reference in New Issue
Block a user