@optilogic/chat 1.0.0-beta.10 → 1.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +575 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +207 -2
- package/dist/index.d.ts +207 -2
- package/dist/index.js +569 -18
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/agent-response/AgentResponse.tsx +31 -13
- package/src/components/agent-response/components/ThinkingSection.tsx +1 -1
- package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +42 -8
- package/src/components/agent-response/index.ts +16 -0
- package/src/components/agent-response/types.ts +42 -0
- package/src/components/agent-timeline/AgentTimeline.tsx +256 -0
- package/src/components/agent-timeline/TimelineAgentBlock.tsx +84 -0
- package/src/components/agent-timeline/TimelineItem.tsx +97 -0
- package/src/components/agent-timeline/index.ts +14 -0
- package/src/components/agent-timeline/types.ts +49 -0
- package/src/components/agent-timeline/utils.ts +167 -0
- package/src/components/hitl-interactions/HITLQuestionPanel.tsx +9 -2
- package/src/components/hitl-interactions/index.ts +1 -1
- package/src/components/inline-actions/ActionMarkdownRenderer.tsx +60 -0
- package/src/components/inline-actions/index.ts +18 -0
- package/src/components/inline-actions/parseResponseSegments.ts +66 -0
- package/src/components/inline-actions/prompts.ts +41 -0
- package/src/components/inline-actions/types.ts +57 -0
- package/src/index.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optilogic/chat",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.11",
|
|
4
4
|
"description": "Chat UI components for Optilogic - AgentResponse and related components for LLM interactions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@optilogic/core": "1.0.0-beta.
|
|
28
|
-
"@optilogic/editor": "1.0.0-beta.
|
|
27
|
+
"@optilogic/core": "1.0.0-beta.11",
|
|
28
|
+
"@optilogic/editor": "1.0.0-beta.11"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as React from "react";
|
|
9
|
-
import { useState, useMemo, useCallback } from "react";
|
|
9
|
+
import { useState, useRef, useMemo, useCallback } from "react";
|
|
10
10
|
import { cn } from "@optilogic/core";
|
|
11
11
|
import { MetadataRow, ThinkingSection, ActionBar, HITLSection } from "./components";
|
|
12
12
|
import { useThinkingTimer } from "./hooks";
|
|
13
13
|
import type { AgentResponseState, FeedbackValue } from "./types";
|
|
14
14
|
import type { HITLInteraction } from "../hitl-interactions";
|
|
15
|
+
import { AgentTimeline, createTimelineUIState } from "../agent-timeline";
|
|
16
|
+
import type { TimelineUIState } from "../agent-timeline";
|
|
15
17
|
|
|
16
18
|
export interface AgentResponseProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
17
19
|
/** The response state to render */
|
|
@@ -147,6 +149,9 @@ const AgentResponse = React.forwardRef<HTMLDivElement, AgentResponseProps>(
|
|
|
147
149
|
},
|
|
148
150
|
ref
|
|
149
151
|
) => {
|
|
152
|
+
// Ref-backed timeline UI state (survives remounts during streaming)
|
|
153
|
+
const timelineUIStateRef = useRef<TimelineUIState>(createTimelineUIState());
|
|
154
|
+
|
|
150
155
|
// Uncontrolled thinking expanded state
|
|
151
156
|
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultThinkingExpanded);
|
|
152
157
|
|
|
@@ -182,9 +187,10 @@ const AgentResponse = React.forwardRef<HTMLDivElement, AgentResponseProps>(
|
|
|
182
187
|
return (state.responseCompleteTime - state.firstMessageTime) / 1000;
|
|
183
188
|
}, [state.firstMessageTime, state.responseCompleteTime]);
|
|
184
189
|
|
|
185
|
-
// Check if we have any thinking content (plain text or
|
|
190
|
+
// Check if we have any thinking content (plain text, structured, or timeline)
|
|
191
|
+
const hasTimelineEntries = !!(state.timelineEntries && state.timelineEntries.length > 0);
|
|
186
192
|
const hasThinkingContent =
|
|
187
|
-
!!state.thinking || (state.thinkingSteps && state.thinkingSteps.length > 0) || false;
|
|
193
|
+
!!state.thinking || (state.thinkingSteps && state.thinkingSteps.length > 0) || hasTimelineEntries || false;
|
|
188
194
|
|
|
189
195
|
const hasHITLInteractions =
|
|
190
196
|
hitlInteractions && hitlInteractions.length > 0;
|
|
@@ -245,16 +251,28 @@ const AgentResponse = React.forwardRef<HTMLDivElement, AgentResponseProps>(
|
|
|
245
251
|
elapsedTime={elapsedTime}
|
|
246
252
|
/>
|
|
247
253
|
|
|
248
|
-
{/* Thinking Content -
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
{/* Thinking Content - AgentTimeline when timeline entries exist, ThinkingSection otherwise */}
|
|
255
|
+
{hasTimelineEntries ? (
|
|
256
|
+
thinkingExpanded && (
|
|
257
|
+
<div className="px-3 pb-3 border-t border-border mt-2">
|
|
258
|
+
<AgentTimeline
|
|
259
|
+
entries={state.timelineEntries!}
|
|
260
|
+
renderMarkdown={renderThinkingMarkdown}
|
|
261
|
+
uiState={timelineUIStateRef.current}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
) : (
|
|
266
|
+
<ThinkingSection
|
|
267
|
+
content={
|
|
268
|
+
state.thinkingSteps && state.thinkingSteps.length > 0
|
|
269
|
+
? state.thinkingSteps
|
|
270
|
+
: state.thinking
|
|
271
|
+
}
|
|
272
|
+
isExpanded={thinkingExpanded}
|
|
273
|
+
renderMarkdown={renderThinkingMarkdown}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
258
276
|
</>
|
|
259
277
|
)}
|
|
260
278
|
|
|
@@ -110,7 +110,7 @@ const ThinkingSection = React.forwardRef<HTMLDivElement, ThinkingSectionProps>(
|
|
|
110
110
|
className={cn("px-3 pb-3 border-t border-border", className)}
|
|
111
111
|
{...props}
|
|
112
112
|
>
|
|
113
|
-
<div className="mt-2 max-h-[200px] overflow-y-auto">
|
|
113
|
+
<div className="mt-2 max-h-[200px] overflow-y-auto scrollbar-thin">
|
|
114
114
|
{isStructured ? (
|
|
115
115
|
<div className="space-y-0">
|
|
116
116
|
{content.map((step) => (
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type StatusItem,
|
|
17
17
|
type ThinkingStep,
|
|
18
18
|
} from "../types";
|
|
19
|
+
import { buildTimelineEntries } from "../../agent-timeline/utils";
|
|
19
20
|
|
|
20
21
|
export interface UseAgentResponseAccumulatorOptions {
|
|
21
22
|
/** WebSocket topic to filter messages (optional, for convenience) */
|
|
@@ -94,33 +95,50 @@ export function useAgentResponseAccumulator(
|
|
|
94
95
|
id: payload.thinkingStep.id || `step-${Date.now()}`,
|
|
95
96
|
label: payload.thinkingStep.label,
|
|
96
97
|
content: payload.thinkingStep.content,
|
|
97
|
-
depth: payload.thinkingStep.depth ?? 0,
|
|
98
|
+
depth: payload.thinkingStep.depth ?? payload.depth ?? 0,
|
|
98
99
|
isCollapsed: payload.thinkingStep.isCollapsed,
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
agentName: payload.agentName,
|
|
102
|
+
parentAgent: payload.parentAgent,
|
|
99
103
|
};
|
|
100
104
|
const thinkingStartTime = prev.thinkingStartTime ?? Date.now();
|
|
101
|
-
|
|
105
|
+
const next = {
|
|
102
106
|
...prev,
|
|
103
107
|
status: newStatus,
|
|
104
108
|
thinkingSteps: [...(prev.thinkingSteps || []), newStep],
|
|
105
109
|
thinkingStartTime,
|
|
106
110
|
firstMessageTime,
|
|
107
111
|
};
|
|
112
|
+
return { ...next, timelineEntries: buildTimelineEntries(next) };
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
// Plain text thinking
|
|
115
|
+
// Plain text thinking — concatenate for backward compat AND
|
|
116
|
+
// push a ThinkingStep so the timeline gets individual entries.
|
|
111
117
|
const newThinking = payload.message || payload.content || "";
|
|
112
118
|
// Add line break between thinking messages
|
|
113
119
|
const separator = prev.thinking && newThinking ? "\n\n" : "";
|
|
114
120
|
// Set thinkingStartTime on first thinking message
|
|
115
121
|
const thinkingStartTime =
|
|
116
122
|
prev.thinkingStartTime ?? (newThinking ? Date.now() : null);
|
|
117
|
-
|
|
123
|
+
const prevSteps = prev.thinkingSteps || [];
|
|
124
|
+
const plainStep: ThinkingStep = {
|
|
125
|
+
id: `step-${prevSteps.length}`,
|
|
126
|
+
label: newThinking,
|
|
127
|
+
content: newThinking,
|
|
128
|
+
depth: payload.depth ?? 0,
|
|
129
|
+
timestamp: Date.now(),
|
|
130
|
+
agentName: payload.agentName,
|
|
131
|
+
parentAgent: payload.parentAgent,
|
|
132
|
+
};
|
|
133
|
+
const next = {
|
|
118
134
|
...prev,
|
|
119
135
|
status: newStatus,
|
|
120
136
|
thinking: prev.thinking + separator + newThinking,
|
|
137
|
+
thinkingSteps: [...prevSteps, plainStep],
|
|
121
138
|
thinkingStartTime,
|
|
122
139
|
firstMessageTime,
|
|
123
140
|
};
|
|
141
|
+
return { ...next, timelineEntries: buildTimelineEntries(next) };
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
case "tool_call": {
|
|
@@ -132,13 +150,17 @@ export function useAgentResponseAccumulator(
|
|
|
132
150
|
name: toolName,
|
|
133
151
|
arguments: payload.tool?.arguments,
|
|
134
152
|
timestamp: Date.now(),
|
|
153
|
+
agentName: payload.agentName,
|
|
154
|
+
parentAgent: payload.parentAgent,
|
|
155
|
+
depth: payload.depth,
|
|
135
156
|
};
|
|
136
|
-
|
|
157
|
+
const next = {
|
|
137
158
|
...prev,
|
|
138
159
|
status: newStatus,
|
|
139
160
|
toolCalls: [...prev.toolCalls, newToolCall],
|
|
140
161
|
firstMessageTime,
|
|
141
162
|
};
|
|
163
|
+
return { ...next, timelineEntries: buildTimelineEntries(next) };
|
|
142
164
|
}
|
|
143
165
|
return { ...prev, status: newStatus, firstMessageTime };
|
|
144
166
|
}
|
|
@@ -152,13 +174,17 @@ export function useAgentResponseAccumulator(
|
|
|
152
174
|
source: payload.knowledge?.source || "unknown",
|
|
153
175
|
content: knowledgeContent,
|
|
154
176
|
timestamp: Date.now(),
|
|
177
|
+
agentName: payload.agentName,
|
|
178
|
+
parentAgent: payload.parentAgent,
|
|
179
|
+
depth: payload.depth,
|
|
155
180
|
};
|
|
156
|
-
|
|
181
|
+
const next = {
|
|
157
182
|
...prev,
|
|
158
183
|
status: newStatus,
|
|
159
184
|
knowledge: [...prev.knowledge, newKnowledge],
|
|
160
185
|
firstMessageTime,
|
|
161
186
|
};
|
|
187
|
+
return { ...next, timelineEntries: buildTimelineEntries(next) };
|
|
162
188
|
}
|
|
163
189
|
return { ...prev, status: newStatus, firstMessageTime };
|
|
164
190
|
}
|
|
@@ -172,13 +198,17 @@ export function useAgentResponseAccumulator(
|
|
|
172
198
|
type: payload.memory?.type || "unknown",
|
|
173
199
|
content: memoryContent,
|
|
174
200
|
timestamp: Date.now(),
|
|
201
|
+
agentName: payload.agentName,
|
|
202
|
+
parentAgent: payload.parentAgent,
|
|
203
|
+
depth: payload.depth,
|
|
175
204
|
};
|
|
176
|
-
|
|
205
|
+
const next = {
|
|
177
206
|
...prev,
|
|
178
207
|
status: newStatus,
|
|
179
208
|
memory: [...prev.memory, newMemory],
|
|
180
209
|
firstMessageTime,
|
|
181
210
|
};
|
|
211
|
+
return { ...next, timelineEntries: buildTimelineEntries(next) };
|
|
182
212
|
}
|
|
183
213
|
return { ...prev, status: newStatus, firstMessageTime };
|
|
184
214
|
}
|
|
@@ -200,13 +230,17 @@ export function useAgentResponseAccumulator(
|
|
|
200
230
|
message: statusMessage,
|
|
201
231
|
agent: payload.statusUpdate?.agent,
|
|
202
232
|
timestamp: Date.now(),
|
|
233
|
+
agentName: payload.agentName,
|
|
234
|
+
parentAgent: payload.parentAgent,
|
|
235
|
+
depth: payload.depth,
|
|
203
236
|
};
|
|
204
|
-
|
|
237
|
+
const next = {
|
|
205
238
|
...prev,
|
|
206
239
|
status: newStatus,
|
|
207
240
|
statusUpdates: [...prev.statusUpdates, newStatusItem],
|
|
208
241
|
firstMessageTime,
|
|
209
242
|
};
|
|
243
|
+
return { ...next, timelineEntries: buildTimelineEntries(next) };
|
|
210
244
|
}
|
|
211
245
|
return { ...prev, status: newStatus, firstMessageTime };
|
|
212
246
|
}
|
|
@@ -53,3 +53,19 @@ export { initialAgentResponseState } from "./types";
|
|
|
53
53
|
|
|
54
54
|
// Utilities
|
|
55
55
|
export { formatTime, formatTotalTime } from "./utils";
|
|
56
|
+
|
|
57
|
+
// Agent Timeline (replaces ThinkingSection for rich execution visibility)
|
|
58
|
+
export {
|
|
59
|
+
AgentTimeline,
|
|
60
|
+
createTimelineUIState,
|
|
61
|
+
buildTimelineEntries,
|
|
62
|
+
groupIntoAgentRuns,
|
|
63
|
+
deduplicateEntries,
|
|
64
|
+
} from "../agent-timeline";
|
|
65
|
+
export type {
|
|
66
|
+
TimelineUIState,
|
|
67
|
+
TimelineEntry,
|
|
68
|
+
TimelineEntryType,
|
|
69
|
+
AgentRun,
|
|
70
|
+
DisplayEntry,
|
|
71
|
+
} from "../agent-timeline";
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Type definitions for the library-ready agent response component
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { TimelineEntry } from "../agent-timeline/types";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Status of the agent response cycle
|
|
9
11
|
*/
|
|
@@ -22,6 +24,12 @@ export interface ToolCall {
|
|
|
22
24
|
name: string;
|
|
23
25
|
arguments?: Record<string, unknown>;
|
|
24
26
|
timestamp: number;
|
|
27
|
+
/** Agent that made this call (multi-agent scenarios) */
|
|
28
|
+
agentName?: string | null;
|
|
29
|
+
/** Parent agent name */
|
|
30
|
+
parentAgent?: string | null;
|
|
31
|
+
/** Nesting depth in agent hierarchy */
|
|
32
|
+
depth?: number;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
/**
|
|
@@ -32,6 +40,12 @@ export interface KnowledgeItem {
|
|
|
32
40
|
source: string;
|
|
33
41
|
content: string;
|
|
34
42
|
timestamp: number;
|
|
43
|
+
/** Agent that retrieved this (multi-agent scenarios) */
|
|
44
|
+
agentName?: string | null;
|
|
45
|
+
/** Parent agent name */
|
|
46
|
+
parentAgent?: string | null;
|
|
47
|
+
/** Nesting depth in agent hierarchy */
|
|
48
|
+
depth?: number;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
/**
|
|
@@ -42,6 +56,12 @@ export interface MemoryItem {
|
|
|
42
56
|
type: string;
|
|
43
57
|
content: string;
|
|
44
58
|
timestamp: number;
|
|
59
|
+
/** Agent that accessed this (multi-agent scenarios) */
|
|
60
|
+
agentName?: string | null;
|
|
61
|
+
/** Parent agent name */
|
|
62
|
+
parentAgent?: string | null;
|
|
63
|
+
/** Nesting depth in agent hierarchy */
|
|
64
|
+
depth?: number;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
/**
|
|
@@ -53,6 +73,12 @@ export interface StatusItem {
|
|
|
53
73
|
timestamp: number;
|
|
54
74
|
/** Optional agent name if in multi-agent scenario */
|
|
55
75
|
agent?: string;
|
|
76
|
+
/** Agent that produced this (multi-agent scenarios) */
|
|
77
|
+
agentName?: string | null;
|
|
78
|
+
/** Parent agent name */
|
|
79
|
+
parentAgent?: string | null;
|
|
80
|
+
/** Nesting depth in agent hierarchy */
|
|
81
|
+
depth?: number;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
/**
|
|
@@ -69,6 +95,12 @@ export interface ThinkingStep {
|
|
|
69
95
|
depth: number;
|
|
70
96
|
/** Whether this step should start collapsed (default: false) */
|
|
71
97
|
isCollapsed?: boolean;
|
|
98
|
+
/** Timestamp for timeline ordering */
|
|
99
|
+
timestamp?: number;
|
|
100
|
+
/** Agent that produced this (multi-agent scenarios) */
|
|
101
|
+
agentName?: string | null;
|
|
102
|
+
/** Parent agent name */
|
|
103
|
+
parentAgent?: string | null;
|
|
72
104
|
}
|
|
73
105
|
|
|
74
106
|
/**
|
|
@@ -98,6 +130,8 @@ export interface AgentResponseState {
|
|
|
98
130
|
statusUpdates: StatusItem[];
|
|
99
131
|
/** Final response text */
|
|
100
132
|
response: string;
|
|
133
|
+
/** Timeline entries derived from all accumulator arrays (for AgentTimeline) */
|
|
134
|
+
timelineEntries?: TimelineEntry[];
|
|
101
135
|
/** Timestamp when first thinking message was received (for timer) */
|
|
102
136
|
thinkingStartTime: number | null;
|
|
103
137
|
/** Timestamp when response was completed (for final timer display) */
|
|
@@ -117,6 +151,14 @@ export interface AgentMessage {
|
|
|
117
151
|
content?: string;
|
|
118
152
|
/** For status messages */
|
|
119
153
|
status?: string;
|
|
154
|
+
/** Agent name (multi-agent scenarios) */
|
|
155
|
+
agentName?: string | null;
|
|
156
|
+
/** Parent agent name (multi-agent scenarios) */
|
|
157
|
+
parentAgent?: string | null;
|
|
158
|
+
/** Agent nesting depth (0 = root) */
|
|
159
|
+
depth?: number;
|
|
160
|
+
/** Title/label for timeline display */
|
|
161
|
+
title?: string | null;
|
|
120
162
|
/** For tool_call messages */
|
|
121
163
|
tool?: {
|
|
122
164
|
id: string;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { useMemo, useRef, useState, useCallback, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Brain,
|
|
4
|
+
Wrench,
|
|
5
|
+
BookOpen,
|
|
6
|
+
HardDrive,
|
|
7
|
+
Activity,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
AlertCircle,
|
|
10
|
+
ChevronsDownUp,
|
|
11
|
+
ChevronsUpDown,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import type { TimelineEntry, TimelineEntryType } from "./types";
|
|
14
|
+
import { groupIntoAgentRuns } from "./utils";
|
|
15
|
+
import { TimelineAgentBlock } from "./TimelineAgentBlock";
|
|
16
|
+
|
|
17
|
+
/** Externalized UI state that survives component remounts */
|
|
18
|
+
export interface TimelineUIState {
|
|
19
|
+
expandedItems: Set<string>;
|
|
20
|
+
collapsedRuns: Set<string>;
|
|
21
|
+
activeFilters: Set<TimelineEntryType>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createTimelineUIState(): TimelineUIState {
|
|
25
|
+
return {
|
|
26
|
+
expandedItems: new Set(),
|
|
27
|
+
collapsedRuns: new Set(),
|
|
28
|
+
activeFilters: new Set(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Icon + label config for each entry type */
|
|
33
|
+
const TYPE_CONFIG: {
|
|
34
|
+
type: TimelineEntryType;
|
|
35
|
+
icon: typeof Brain;
|
|
36
|
+
label: string;
|
|
37
|
+
}[] = [
|
|
38
|
+
{ type: "status_update", icon: Activity, label: "Status" },
|
|
39
|
+
{ type: "thinking", icon: Brain, label: "Thinking" },
|
|
40
|
+
{ type: "tool_call", icon: Wrench, label: "Tools" },
|
|
41
|
+
{ type: "knowledge", icon: BookOpen, label: "Knowledge" },
|
|
42
|
+
{ type: "memory", icon: HardDrive, label: "Memory" },
|
|
43
|
+
{ type: "ai_response", icon: MessageSquare, label: "AI" },
|
|
44
|
+
{ type: "error", icon: AlertCircle, label: "Errors" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
interface AgentTimelineProps {
|
|
48
|
+
entries: TimelineEntry[];
|
|
49
|
+
renderMarkdown?: (content: string) => ReactNode;
|
|
50
|
+
/**
|
|
51
|
+
* External UI state store. When provided, expand/collapse/filter state
|
|
52
|
+
* is read from and written to this object (which should be ref-backed
|
|
53
|
+
* in the parent) so it survives component remounts during streaming.
|
|
54
|
+
*/
|
|
55
|
+
uiState?: TimelineUIState;
|
|
56
|
+
/**
|
|
57
|
+
* Maximum height of the scrollable timeline container.
|
|
58
|
+
* Defaults to "300px". Set to "none" to disable.
|
|
59
|
+
*/
|
|
60
|
+
maxHeight?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function AgentTimeline({ entries, renderMarkdown, uiState, maxHeight = "300px" }: AgentTimelineProps) {
|
|
64
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
|
|
66
|
+
// Render tick: forces re-renders AND invalidates useMemo deps when we mutate Sets in-place
|
|
67
|
+
const [renderTick, setRenderTick] = useState(0);
|
|
68
|
+
const forceRender = useCallback(() => setRenderTick((t) => t + 1), []);
|
|
69
|
+
|
|
70
|
+
// Resolve state: prefer external uiState (ref-backed, survives remounts)
|
|
71
|
+
// Fall back to internal state if no external store provided
|
|
72
|
+
const [internalExpandedItems] = useState<Set<string>>(() => new Set());
|
|
73
|
+
const [internalCollapsedRuns] = useState<Set<string>>(() => new Set());
|
|
74
|
+
const [internalActiveFilters] = useState<Set<TimelineEntryType>>(() => new Set());
|
|
75
|
+
|
|
76
|
+
const expandedItems = uiState?.expandedItems ?? internalExpandedItems;
|
|
77
|
+
const collapsedRuns = uiState?.collapsedRuns ?? internalCollapsedRuns;
|
|
78
|
+
const activeFilters = uiState?.activeFilters ?? internalActiveFilters;
|
|
79
|
+
|
|
80
|
+
// Compute which types actually exist in the entries
|
|
81
|
+
const availableTypes = useMemo(() => {
|
|
82
|
+
const types = new Set<TimelineEntryType>();
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
types.add(entry.type);
|
|
85
|
+
}
|
|
86
|
+
return types;
|
|
87
|
+
}, [entries]);
|
|
88
|
+
|
|
89
|
+
// Filter entries, then group into runs
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- renderTick invalidates when mutated Sets change
|
|
91
|
+
const filteredEntries = useMemo(
|
|
92
|
+
() =>
|
|
93
|
+
activeFilters.size === 0
|
|
94
|
+
? entries
|
|
95
|
+
: entries.filter((e) => activeFilters.has(e.type)),
|
|
96
|
+
[entries, activeFilters, renderTick],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
const agentRuns = useMemo(() => groupIntoAgentRuns(filteredEntries), [filteredEntries, renderTick]);
|
|
101
|
+
|
|
102
|
+
// --- Mutators (mutate the set in-place + trigger re-render) ---
|
|
103
|
+
|
|
104
|
+
const toggleFilter = useCallback((type: TimelineEntryType) => {
|
|
105
|
+
if (activeFilters.has(type)) {
|
|
106
|
+
activeFilters.delete(type);
|
|
107
|
+
} else {
|
|
108
|
+
activeFilters.add(type);
|
|
109
|
+
}
|
|
110
|
+
forceRender();
|
|
111
|
+
}, [activeFilters, forceRender]);
|
|
112
|
+
|
|
113
|
+
const clearFilters = useCallback(() => {
|
|
114
|
+
activeFilters.clear();
|
|
115
|
+
forceRender();
|
|
116
|
+
}, [activeFilters, forceRender]);
|
|
117
|
+
|
|
118
|
+
const toggleItemExpanded = useCallback((entryId: string) => {
|
|
119
|
+
if (expandedItems.has(entryId)) {
|
|
120
|
+
expandedItems.delete(entryId);
|
|
121
|
+
} else {
|
|
122
|
+
expandedItems.add(entryId);
|
|
123
|
+
}
|
|
124
|
+
forceRender();
|
|
125
|
+
}, [expandedItems, forceRender]);
|
|
126
|
+
|
|
127
|
+
const collapseAll = useCallback(() => {
|
|
128
|
+
collapsedRuns.clear();
|
|
129
|
+
agentRuns.forEach((run, i) => {
|
|
130
|
+
collapsedRuns.add(`${run.agentName}-${i}`);
|
|
131
|
+
});
|
|
132
|
+
expandedItems.clear();
|
|
133
|
+
forceRender();
|
|
134
|
+
}, [agentRuns, collapsedRuns, expandedItems, forceRender]);
|
|
135
|
+
|
|
136
|
+
const expandAll = useCallback(() => {
|
|
137
|
+
collapsedRuns.clear();
|
|
138
|
+
agentRuns.forEach((run, i) => {
|
|
139
|
+
collapsedRuns.add(`${run.agentName}-${i}:expanded`);
|
|
140
|
+
});
|
|
141
|
+
forceRender();
|
|
142
|
+
}, [agentRuns, collapsedRuns, forceRender]);
|
|
143
|
+
|
|
144
|
+
if (entries.length === 0) return null;
|
|
145
|
+
|
|
146
|
+
const isSingle = agentRuns.length === 1;
|
|
147
|
+
const hasActiveFilter = activeFilters.size > 0;
|
|
148
|
+
|
|
149
|
+
const scrollStyle = maxHeight !== "none" ? { maxHeight } : undefined;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
ref={containerRef}
|
|
154
|
+
className={`-mt-1 ${maxHeight !== "none" ? "overflow-y-auto scrollbar-thin" : ""}`}
|
|
155
|
+
style={scrollStyle}
|
|
156
|
+
>
|
|
157
|
+
{/* Filter + controls bar (sticky within scroll container) */}
|
|
158
|
+
<div className="sticky top-0 z-10 bg-background flex items-center gap-1 py-1.5 mb-1 border-b border-border/50 flex-wrap">
|
|
159
|
+
{/* Type filter chips */}
|
|
160
|
+
{TYPE_CONFIG.filter((tc) => availableTypes.has(tc.type)).map((tc) => {
|
|
161
|
+
const isActive = activeFilters.has(tc.type);
|
|
162
|
+
const count = entries.filter((e) => e.type === tc.type).length;
|
|
163
|
+
return (
|
|
164
|
+
<button
|
|
165
|
+
key={tc.type}
|
|
166
|
+
onClick={() => toggleFilter(tc.type)}
|
|
167
|
+
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] transition-colors ${
|
|
168
|
+
isActive
|
|
169
|
+
? "bg-accent text-accent-foreground ring-1 ring-accent-foreground/20"
|
|
170
|
+
: "text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50"
|
|
171
|
+
}`}
|
|
172
|
+
title={`${isActive ? "Hide" : "Show only"} ${tc.label}`}
|
|
173
|
+
>
|
|
174
|
+
<tc.icon className="w-3 h-3" />
|
|
175
|
+
<span>{count}</span>
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
|
|
180
|
+
{/* Clear filter button */}
|
|
181
|
+
{hasActiveFilter && (
|
|
182
|
+
<button
|
|
183
|
+
onClick={clearFilters}
|
|
184
|
+
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground px-1"
|
|
185
|
+
>
|
|
186
|
+
Clear
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{/* Spacer */}
|
|
191
|
+
<div className="flex-1" />
|
|
192
|
+
|
|
193
|
+
{/* Collapse/expand all */}
|
|
194
|
+
{!isSingle && (
|
|
195
|
+
<>
|
|
196
|
+
<button
|
|
197
|
+
onClick={collapseAll}
|
|
198
|
+
className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground px-1 py-0.5 rounded hover:bg-muted/50 transition-colors"
|
|
199
|
+
title="Collapse all"
|
|
200
|
+
>
|
|
201
|
+
<ChevronsDownUp className="w-3 h-3" />
|
|
202
|
+
</button>
|
|
203
|
+
<button
|
|
204
|
+
onClick={expandAll}
|
|
205
|
+
className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground px-1 py-0.5 rounded hover:bg-muted/50 transition-colors"
|
|
206
|
+
title="Expand all"
|
|
207
|
+
>
|
|
208
|
+
<ChevronsUpDown className="w-3 h-3" />
|
|
209
|
+
</button>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Timeline content */}
|
|
215
|
+
{filteredEntries.length === 0 ? (
|
|
216
|
+
<div className="text-[10px] text-muted-foreground/50 py-2 text-center">
|
|
217
|
+
No entries match the selected filters
|
|
218
|
+
</div>
|
|
219
|
+
) : (
|
|
220
|
+
<div className="space-y-0.5">
|
|
221
|
+
{agentRuns.map((run, i) => {
|
|
222
|
+
const runKey = `${run.agentName}-${i}`;
|
|
223
|
+
const defaultCollapsed = run.depth > 0;
|
|
224
|
+
const isCollapsed = collapsedRuns.has(runKey)
|
|
225
|
+
? true
|
|
226
|
+
: collapsedRuns.has(`${runKey}:expanded`)
|
|
227
|
+
? false
|
|
228
|
+
: defaultCollapsed;
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<TimelineAgentBlock
|
|
232
|
+
key={runKey}
|
|
233
|
+
block={run}
|
|
234
|
+
renderMarkdown={renderMarkdown}
|
|
235
|
+
isSingleAgent={isSingle}
|
|
236
|
+
isCollapsed={isCollapsed}
|
|
237
|
+
onToggleCollapsed={() => {
|
|
238
|
+
if (isCollapsed) {
|
|
239
|
+
collapsedRuns.delete(runKey);
|
|
240
|
+
collapsedRuns.add(`${runKey}:expanded`);
|
|
241
|
+
} else {
|
|
242
|
+
collapsedRuns.delete(`${runKey}:expanded`);
|
|
243
|
+
collapsedRuns.add(runKey);
|
|
244
|
+
}
|
|
245
|
+
forceRender();
|
|
246
|
+
}}
|
|
247
|
+
expandedItems={expandedItems}
|
|
248
|
+
onToggleItemExpanded={toggleItemExpanded}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|