@optilogic/chat 1.0.0-beta.8 → 1.0.0
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/README.md +136 -0
- package/dist/index.cjs +989 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +361 -2
- package/dist/index.d.ts +361 -2
- package/dist/index.js +964 -46
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/agent-response/AgentResponse.tsx +86 -14
- package/src/components/agent-response/components/HITLSection.tsx +95 -0
- package/src/components/agent-response/components/MetadataRow.tsx +15 -4
- package/src/components/agent-response/components/TruncatedMessage.tsx +52 -0
- package/src/components/agent-response/components/index.ts +6 -0
- package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +65 -8
- package/src/components/agent-response/index.ts +21 -0
- package/src/components/agent-response/types.ts +61 -1
- 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 +189 -0
- package/src/components/hitl-interactions/HITLInteractionRecord.tsx +139 -0
- package/src/components/hitl-interactions/HITLQuestionPanel.tsx +270 -0
- package/src/components/hitl-interactions/index.ts +18 -0
- 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/components/user-prompt-input/UserPromptInput.tsx +13 -8
- package/src/components/user-prompt-input/types.ts +4 -0
- package/src/index.ts +42 -0
|
@@ -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,24 @@ 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;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Potential response (sub-agent intermediate AI response)
|
|
69
|
+
*/
|
|
70
|
+
export interface PotentialResponse {
|
|
71
|
+
id: string;
|
|
72
|
+
content: string;
|
|
73
|
+
timestamp: number;
|
|
74
|
+
agentName?: string | null;
|
|
75
|
+
parentAgent?: string | null;
|
|
76
|
+
depth?: number;
|
|
45
77
|
}
|
|
46
78
|
|
|
47
79
|
/**
|
|
@@ -53,6 +85,12 @@ export interface StatusItem {
|
|
|
53
85
|
timestamp: number;
|
|
54
86
|
/** Optional agent name if in multi-agent scenario */
|
|
55
87
|
agent?: string;
|
|
88
|
+
/** Agent that produced this (multi-agent scenarios) */
|
|
89
|
+
agentName?: string | null;
|
|
90
|
+
/** Parent agent name */
|
|
91
|
+
parentAgent?: string | null;
|
|
92
|
+
/** Nesting depth in agent hierarchy */
|
|
93
|
+
depth?: number;
|
|
56
94
|
}
|
|
57
95
|
|
|
58
96
|
/**
|
|
@@ -69,6 +107,12 @@ export interface ThinkingStep {
|
|
|
69
107
|
depth: number;
|
|
70
108
|
/** Whether this step should start collapsed (default: false) */
|
|
71
109
|
isCollapsed?: boolean;
|
|
110
|
+
/** Timestamp for timeline ordering */
|
|
111
|
+
timestamp?: number;
|
|
112
|
+
/** Agent that produced this (multi-agent scenarios) */
|
|
113
|
+
agentName?: string | null;
|
|
114
|
+
/** Parent agent name */
|
|
115
|
+
parentAgent?: string | null;
|
|
72
116
|
}
|
|
73
117
|
|
|
74
118
|
/**
|
|
@@ -96,8 +140,14 @@ export interface AgentResponseState {
|
|
|
96
140
|
memory: MemoryItem[];
|
|
97
141
|
/** Status updates from the agent */
|
|
98
142
|
statusUpdates: StatusItem[];
|
|
143
|
+
/** Potential responses (sub-agent intermediate AI responses) */
|
|
144
|
+
potentialResponses?: PotentialResponse[];
|
|
145
|
+
/** Custom timeline entries (consumer-provided) */
|
|
146
|
+
customTimelineEntries?: TimelineEntry[];
|
|
99
147
|
/** Final response text */
|
|
100
148
|
response: string;
|
|
149
|
+
/** Timeline entries derived from all accumulator arrays (for AgentTimeline) */
|
|
150
|
+
timelineEntries?: TimelineEntry[];
|
|
101
151
|
/** Timestamp when first thinking message was received (for timer) */
|
|
102
152
|
thinkingStartTime: number | null;
|
|
103
153
|
/** Timestamp when response was completed (for final timer display) */
|
|
@@ -110,13 +160,21 @@ export interface AgentResponseState {
|
|
|
110
160
|
* WebSocket message payload for agent responses
|
|
111
161
|
*/
|
|
112
162
|
export interface AgentMessage {
|
|
113
|
-
type: "status" | "thinking" | "tool_call" | "knowledge" | "memory" | "response" | "status_update";
|
|
163
|
+
type: "status" | "thinking" | "tool_call" | "knowledge" | "memory" | "response" | "status_update" | "potential_response";
|
|
114
164
|
/** Message content - for simple string payloads */
|
|
115
165
|
message?: string;
|
|
116
166
|
/** Alternative content field */
|
|
117
167
|
content?: string;
|
|
118
168
|
/** For status messages */
|
|
119
169
|
status?: string;
|
|
170
|
+
/** Agent name (multi-agent scenarios) */
|
|
171
|
+
agentName?: string | null;
|
|
172
|
+
/** Parent agent name (multi-agent scenarios) */
|
|
173
|
+
parentAgent?: string | null;
|
|
174
|
+
/** Agent nesting depth (0 = root) */
|
|
175
|
+
depth?: number;
|
|
176
|
+
/** Title/label for timeline display */
|
|
177
|
+
title?: string | null;
|
|
120
178
|
/** For tool_call messages */
|
|
121
179
|
tool?: {
|
|
122
180
|
id: string;
|
|
@@ -170,6 +228,8 @@ export const initialAgentResponseState: AgentResponseState = {
|
|
|
170
228
|
knowledge: [],
|
|
171
229
|
memory: [],
|
|
172
230
|
statusUpdates: [],
|
|
231
|
+
potentialResponses: [],
|
|
232
|
+
customTimelineEntries: [],
|
|
173
233
|
response: "",
|
|
174
234
|
thinkingStartTime: null,
|
|
175
235
|
responseCompleteTime: null,
|
|
@@ -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={maxHeight !== "none" ? "overflow-y-auto" : ""}
|
|
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 pl-2">
|
|
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
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
|
+
import type { AgentRun } from "./types";
|
|
4
|
+
import { TimelineItem } from "./TimelineItem";
|
|
5
|
+
|
|
6
|
+
interface TimelineAgentBlockProps {
|
|
7
|
+
block: AgentRun;
|
|
8
|
+
renderMarkdown?: (content: string) => ReactNode;
|
|
9
|
+
/** If true, skip the collapsible header and render entries directly */
|
|
10
|
+
isSingleAgent: boolean;
|
|
11
|
+
/** Controlled collapsed state (lifted to AgentTimeline) */
|
|
12
|
+
isCollapsed: boolean;
|
|
13
|
+
onToggleCollapsed: () => void;
|
|
14
|
+
/** Set of entry IDs whose content is expanded (lifted to AgentTimeline) */
|
|
15
|
+
expandedItems: Set<string>;
|
|
16
|
+
onToggleItemExpanded: (entryId: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TimelineAgentBlock({
|
|
20
|
+
block,
|
|
21
|
+
renderMarkdown,
|
|
22
|
+
isSingleAgent,
|
|
23
|
+
isCollapsed,
|
|
24
|
+
onToggleCollapsed,
|
|
25
|
+
expandedItems,
|
|
26
|
+
onToggleItemExpanded,
|
|
27
|
+
}: TimelineAgentBlockProps) {
|
|
28
|
+
const indentPx = block.depth * 16;
|
|
29
|
+
|
|
30
|
+
// Skip header only for a single root-level agent (depth 0).
|
|
31
|
+
// If filtering leaves only a sub-agent, still show the header for context.
|
|
32
|
+
if (isSingleAgent && block.depth === 0) {
|
|
33
|
+
return (
|
|
34
|
+
<div style={{ paddingLeft: `${indentPx}px` }}>
|
|
35
|
+
{block.entries.map((displayEntry, i) => (
|
|
36
|
+
<TimelineItem
|
|
37
|
+
key={displayEntry.entry.id + "-" + i}
|
|
38
|
+
displayEntry={displayEntry}
|
|
39
|
+
renderMarkdown={renderMarkdown}
|
|
40
|
+
isExpanded={expandedItems.has(displayEntry.entry.id)}
|
|
41
|
+
onToggleExpanded={() => onToggleItemExpanded(displayEntry.entry.id)}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ paddingLeft: `${indentPx}px` }}>
|
|
50
|
+
{/* Agent header */}
|
|
51
|
+
<button
|
|
52
|
+
onClick={onToggleCollapsed}
|
|
53
|
+
className="w-full flex items-center gap-1.5 py-1 hover:bg-muted/50 -ml-1 pl-1 pr-2 rounded transition-colors text-left"
|
|
54
|
+
>
|
|
55
|
+
{isCollapsed ? (
|
|
56
|
+
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
57
|
+
) : (
|
|
58
|
+
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
59
|
+
)}
|
|
60
|
+
<span className="text-xs font-medium text-foreground/80">
|
|
61
|
+
{block.agentName}
|
|
62
|
+
</span>
|
|
63
|
+
<span className="text-[10px] text-muted-foreground/60">
|
|
64
|
+
({block.entries.reduce((sum, e) => sum + e.count, 0)})
|
|
65
|
+
</span>
|
|
66
|
+
</button>
|
|
67
|
+
|
|
68
|
+
{/* Entries */}
|
|
69
|
+
{!isCollapsed && (
|
|
70
|
+
<div className="ml-4">
|
|
71
|
+
{block.entries.map((displayEntry, i) => (
|
|
72
|
+
<TimelineItem
|
|
73
|
+
key={displayEntry.entry.id + "-" + i}
|
|
74
|
+
displayEntry={displayEntry}
|
|
75
|
+
renderMarkdown={renderMarkdown}
|
|
76
|
+
isExpanded={expandedItems.has(displayEntry.entry.id)}
|
|
77
|
+
onToggleExpanded={() => onToggleItemExpanded(displayEntry.entry.id)}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Brain,
|
|
4
|
+
Wrench,
|
|
5
|
+
BookOpen,
|
|
6
|
+
HardDrive,
|
|
7
|
+
Activity,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
AlertCircle,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import type { DisplayEntry, TimelineEntryType } from "./types";
|
|
12
|
+
|
|
13
|
+
const ICON_MAP: Record<TimelineEntryType, typeof Brain> = {
|
|
14
|
+
thinking: Brain,
|
|
15
|
+
tool_call: Wrench,
|
|
16
|
+
knowledge: BookOpen,
|
|
17
|
+
memory: HardDrive,
|
|
18
|
+
status_update: Activity,
|
|
19
|
+
ai_response: MessageSquare,
|
|
20
|
+
error: AlertCircle,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface TimelineItemProps {
|
|
24
|
+
displayEntry: DisplayEntry;
|
|
25
|
+
renderMarkdown?: (content: string) => ReactNode;
|
|
26
|
+
/** Controlled expanded state (lifted to AgentTimeline) */
|
|
27
|
+
isExpanded: boolean;
|
|
28
|
+
onToggleExpanded: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function TimelineItem({
|
|
32
|
+
displayEntry,
|
|
33
|
+
renderMarkdown,
|
|
34
|
+
isExpanded,
|
|
35
|
+
onToggleExpanded,
|
|
36
|
+
}: TimelineItemProps) {
|
|
37
|
+
const { entry, count } = displayEntry;
|
|
38
|
+
|
|
39
|
+
const Icon = ICON_MAP[entry.type] ?? Activity;
|
|
40
|
+
|
|
41
|
+
// Determine if content is long enough to warrant truncation
|
|
42
|
+
const isLong = entry.content.length > 200 || entry.content.split("\n").length > 3;
|
|
43
|
+
const canExpand = isLong || entry.type === "ai_response";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="py-1 flex items-start gap-2 group">
|
|
47
|
+
{/* Type icon */}
|
|
48
|
+
<Icon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
49
|
+
|
|
50
|
+
{/* Content */}
|
|
51
|
+
<div className="min-w-0 flex-1">
|
|
52
|
+
{isExpanded && entry.type === "ai_response" && renderMarkdown ? (
|
|
53
|
+
// Expanded AI response: rendered markdown
|
|
54
|
+
<div>
|
|
55
|
+
{renderMarkdown(entry.content)}
|
|
56
|
+
<button
|
|
57
|
+
onClick={onToggleExpanded}
|
|
58
|
+
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1"
|
|
59
|
+
>
|
|
60
|
+
Show less
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
) : isExpanded ? (
|
|
64
|
+
// Expanded non-AI: plain text
|
|
65
|
+
<div>
|
|
66
|
+
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
|
67
|
+
{entry.content}
|
|
68
|
+
</pre>
|
|
69
|
+
<button
|
|
70
|
+
onClick={onToggleExpanded}
|
|
71
|
+
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1"
|
|
72
|
+
>
|
|
73
|
+
Show less
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
) : (
|
|
77
|
+
// Collapsed: truncated with optional expand
|
|
78
|
+
<div className="flex items-baseline gap-1.5 min-w-0">
|
|
79
|
+
<div
|
|
80
|
+
className={`text-xs text-muted-foreground min-w-0 ${canExpand ? "line-clamp-2 cursor-pointer hover:text-foreground/80" : ""}`}
|
|
81
|
+
onClick={canExpand ? onToggleExpanded : undefined}
|
|
82
|
+
>
|
|
83
|
+
{entry.content}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Dedup count badge */}
|
|
87
|
+
{count > 1 && (
|
|
88
|
+
<span className="text-[10px] text-muted-foreground/60 whitespace-nowrap flex-shrink-0">
|
|
89
|
+
(x{count})
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { AgentTimeline, createTimelineUIState } from "./AgentTimeline";
|
|
3
|
+
export type { TimelineUIState } from "./AgentTimeline";
|
|
4
|
+
|
|
5
|
+
// Utilities
|
|
6
|
+
export { buildTimelineEntries, groupIntoAgentRuns, deduplicateEntries } from "./utils";
|
|
7
|
+
|
|
8
|
+
// Types
|
|
9
|
+
export type {
|
|
10
|
+
TimelineEntry,
|
|
11
|
+
TimelineEntryType,
|
|
12
|
+
AgentRun,
|
|
13
|
+
DisplayEntry,
|
|
14
|
+
} from "./types";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** All possible timeline entry types */
|
|
2
|
+
export type TimelineEntryType =
|
|
3
|
+
| "thinking"
|
|
4
|
+
| "tool_call"
|
|
5
|
+
| "knowledge"
|
|
6
|
+
| "memory"
|
|
7
|
+
| "status_update"
|
|
8
|
+
| "ai_response"
|
|
9
|
+
| "error";
|
|
10
|
+
|
|
11
|
+
/** A single event in the agent execution timeline */
|
|
12
|
+
export interface TimelineEntry {
|
|
13
|
+
/** Unique ID for React keys */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Discriminated type for icon and rendering */
|
|
16
|
+
type: TimelineEntryType;
|
|
17
|
+
/** The agent that produced this entry */
|
|
18
|
+
agentName: string | null;
|
|
19
|
+
/** The parent agent (null for root agent) */
|
|
20
|
+
parentAgent: string | null;
|
|
21
|
+
/** Nesting depth (0 = root) */
|
|
22
|
+
depth: number;
|
|
23
|
+
/** Display content (may be long) */
|
|
24
|
+
content: string;
|
|
25
|
+
/** Original title from the WebSocket message */
|
|
26
|
+
title: string | null;
|
|
27
|
+
/** Millisecond timestamp for ordering */
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A consecutive run of messages from the same agent */
|
|
32
|
+
export interface AgentRun {
|
|
33
|
+
/** Agent name (display label) */
|
|
34
|
+
agentName: string;
|
|
35
|
+
/** Parent agent name (null for root) */
|
|
36
|
+
parentAgent: string | null;
|
|
37
|
+
/** Depth in agent hierarchy */
|
|
38
|
+
depth: number;
|
|
39
|
+
/** The entries belonging to this run, in order, after dedup */
|
|
40
|
+
entries: DisplayEntry[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** After dedup — count > 1 means consecutive identical messages collapsed */
|
|
44
|
+
export interface DisplayEntry {
|
|
45
|
+
/** The underlying timeline entry (first of the group if deduped) */
|
|
46
|
+
entry: TimelineEntry;
|
|
47
|
+
/** How many consecutive identical messages were collapsed into this one */
|
|
48
|
+
count: number;
|
|
49
|
+
}
|