@optilogic/chat 1.0.0-beta.1 → 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.
Files changed (38) hide show
  1. package/README.md +235 -0
  2. package/dist/index.cjs +1292 -43
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +524 -6
  5. package/dist/index.d.ts +524 -6
  6. package/dist/index.js +1267 -33
  7. package/dist/index.js.map +1 -1
  8. package/package.json +15 -9
  9. package/src/components/agent-response/AgentResponse.tsx +99 -10
  10. package/src/components/agent-response/components/ActivityIndicators.tsx +36 -4
  11. package/src/components/agent-response/components/HITLSection.tsx +95 -0
  12. package/src/components/agent-response/components/MetadataRow.tsx +21 -6
  13. package/src/components/agent-response/components/ThinkingSection.tsx +102 -10
  14. package/src/components/agent-response/components/TruncatedMessage.tsx +52 -0
  15. package/src/components/agent-response/components/index.ts +6 -0
  16. package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +79 -4
  17. package/src/components/agent-response/index.ts +23 -0
  18. package/src/components/agent-response/types.ts +96 -1
  19. package/src/components/agent-timeline/AgentTimeline.tsx +256 -0
  20. package/src/components/agent-timeline/TimelineAgentBlock.tsx +84 -0
  21. package/src/components/agent-timeline/TimelineItem.tsx +97 -0
  22. package/src/components/agent-timeline/index.ts +14 -0
  23. package/src/components/agent-timeline/types.ts +49 -0
  24. package/src/components/agent-timeline/utils.ts +167 -0
  25. package/src/components/hitl-interactions/HITLInteractionRecord.tsx +139 -0
  26. package/src/components/hitl-interactions/HITLQuestionPanel.tsx +270 -0
  27. package/src/components/hitl-interactions/index.ts +18 -0
  28. package/src/components/inline-actions/ActionMarkdownRenderer.tsx +60 -0
  29. package/src/components/inline-actions/index.ts +18 -0
  30. package/src/components/inline-actions/parseResponseSegments.ts +66 -0
  31. package/src/components/inline-actions/prompts.ts +41 -0
  32. package/src/components/inline-actions/types.ts +57 -0
  33. package/src/components/user-prompt/UserPrompt.tsx +60 -0
  34. package/src/components/user-prompt/index.ts +1 -0
  35. package/src/components/user-prompt-input/UserPromptInput.tsx +326 -0
  36. package/src/components/user-prompt-input/index.ts +2 -0
  37. package/src/components/user-prompt-input/types.ts +52 -0
  38. package/src/index.ts +54 -0
@@ -13,7 +13,10 @@ import {
13
13
  type ToolCall,
14
14
  type KnowledgeItem,
15
15
  type MemoryItem,
16
+ type StatusItem,
17
+ type ThinkingStep,
16
18
  } from "../types";
19
+ import { buildTimelineEntries } from "../../agent-timeline/utils";
17
20
 
18
21
  export interface UseAgentResponseAccumulatorOptions {
19
22
  /** WebSocket topic to filter messages (optional, for convenience) */
@@ -86,19 +89,56 @@ export function useAgentResponseAccumulator(
86
89
  return { ...prev, status: newStatus };
87
90
 
88
91
  case "thinking": {
92
+ // Check if this is a structured thinking step
93
+ if (payload.thinkingStep) {
94
+ const newStep: ThinkingStep = {
95
+ id: payload.thinkingStep.id || `step-${Date.now()}`,
96
+ label: payload.thinkingStep.label,
97
+ content: payload.thinkingStep.content,
98
+ depth: payload.thinkingStep.depth ?? payload.depth ?? 0,
99
+ isCollapsed: payload.thinkingStep.isCollapsed,
100
+ timestamp: Date.now(),
101
+ agentName: payload.agentName,
102
+ parentAgent: payload.parentAgent,
103
+ };
104
+ const thinkingStartTime = prev.thinkingStartTime ?? Date.now();
105
+ const next = {
106
+ ...prev,
107
+ status: newStatus,
108
+ thinkingSteps: [...(prev.thinkingSteps || []), newStep],
109
+ thinkingStartTime,
110
+ firstMessageTime,
111
+ };
112
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
113
+ }
114
+
115
+ // Plain text thinking — concatenate for backward compat AND
116
+ // push a ThinkingStep so the timeline gets individual entries.
89
117
  const newThinking = payload.message || payload.content || "";
90
118
  // Add line break between thinking messages
91
119
  const separator = prev.thinking && newThinking ? "\n\n" : "";
92
120
  // Set thinkingStartTime on first thinking message
93
121
  const thinkingStartTime =
94
122
  prev.thinkingStartTime ?? (newThinking ? Date.now() : null);
95
- return {
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 = {
96
134
  ...prev,
97
135
  status: newStatus,
98
136
  thinking: prev.thinking + separator + newThinking,
137
+ thinkingSteps: [...prevSteps, plainStep],
99
138
  thinkingStartTime,
100
139
  firstMessageTime,
101
140
  };
141
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
102
142
  }
103
143
 
104
144
  case "tool_call": {
@@ -110,13 +150,17 @@ export function useAgentResponseAccumulator(
110
150
  name: toolName,
111
151
  arguments: payload.tool?.arguments,
112
152
  timestamp: Date.now(),
153
+ agentName: payload.agentName,
154
+ parentAgent: payload.parentAgent,
155
+ depth: payload.depth,
113
156
  };
114
- return {
157
+ const next = {
115
158
  ...prev,
116
159
  status: newStatus,
117
160
  toolCalls: [...prev.toolCalls, newToolCall],
118
161
  firstMessageTime,
119
162
  };
163
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
120
164
  }
121
165
  return { ...prev, status: newStatus, firstMessageTime };
122
166
  }
@@ -130,13 +174,17 @@ export function useAgentResponseAccumulator(
130
174
  source: payload.knowledge?.source || "unknown",
131
175
  content: knowledgeContent,
132
176
  timestamp: Date.now(),
177
+ agentName: payload.agentName,
178
+ parentAgent: payload.parentAgent,
179
+ depth: payload.depth,
133
180
  };
134
- return {
181
+ const next = {
135
182
  ...prev,
136
183
  status: newStatus,
137
184
  knowledge: [...prev.knowledge, newKnowledge],
138
185
  firstMessageTime,
139
186
  };
187
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
140
188
  }
141
189
  return { ...prev, status: newStatus, firstMessageTime };
142
190
  }
@@ -150,13 +198,17 @@ export function useAgentResponseAccumulator(
150
198
  type: payload.memory?.type || "unknown",
151
199
  content: memoryContent,
152
200
  timestamp: Date.now(),
201
+ agentName: payload.agentName,
202
+ parentAgent: payload.parentAgent,
203
+ depth: payload.depth,
153
204
  };
154
- return {
205
+ const next = {
155
206
  ...prev,
156
207
  status: newStatus,
157
208
  memory: [...prev.memory, newMemory],
158
209
  firstMessageTime,
159
210
  };
211
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
160
212
  }
161
213
  return { ...prev, status: newStatus, firstMessageTime };
162
214
  }
@@ -170,6 +222,29 @@ export function useAgentResponseAccumulator(
170
222
  firstMessageTime: prev.firstMessageTime ?? Date.now(),
171
223
  };
172
224
 
225
+ case "status_update": {
226
+ const statusMessage = payload.message || payload.statusUpdate?.message;
227
+ if (statusMessage) {
228
+ const newStatusItem: StatusItem = {
229
+ id: payload.statusUpdate?.id || `status-${Date.now()}`,
230
+ message: statusMessage,
231
+ agent: payload.statusUpdate?.agent,
232
+ timestamp: Date.now(),
233
+ agentName: payload.agentName,
234
+ parentAgent: payload.parentAgent,
235
+ depth: payload.depth,
236
+ };
237
+ const next = {
238
+ ...prev,
239
+ status: newStatus,
240
+ statusUpdates: [...prev.statusUpdates, newStatusItem],
241
+ firstMessageTime,
242
+ };
243
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
244
+ }
245
+ return { ...prev, status: newStatus, firstMessageTime };
246
+ }
247
+
173
248
  default:
174
249
  return { ...prev, status: newStatus, firstMessageTime };
175
250
  }
@@ -15,10 +15,14 @@ export {
15
15
  MetadataRow,
16
16
  ThinkingSection,
17
17
  ActionBar,
18
+ HITLSection,
19
+ TruncatedMessage,
18
20
  type ActivityIndicatorsProps,
19
21
  type MetadataRowProps,
20
22
  type ThinkingSectionProps,
21
23
  type ActionBarProps,
24
+ type HITLSectionProps,
25
+ type TruncatedMessageProps,
22
26
  } from "./components";
23
27
 
24
28
  // Hooks
@@ -38,6 +42,9 @@ export type {
38
42
  ToolCall,
39
43
  KnowledgeItem,
40
44
  MemoryItem,
45
+ StatusItem,
46
+ ThinkingStep,
47
+ ThinkingContent,
41
48
  AgentMessage,
42
49
  GenericWebSocketMessage,
43
50
  } from "./types";
@@ -46,3 +53,19 @@ export { initialAgentResponseState } from "./types";
46
53
 
47
54
  // Utilities
48
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,8 +56,60 @@ 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
+ * Status update information from the agent
69
+ */
70
+ export interface StatusItem {
71
+ id: string;
72
+ message: string;
73
+ timestamp: number;
74
+ /** Optional agent name if in multi-agent scenario */
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;
82
+ }
83
+
84
+ /**
85
+ * A single step in structured thinking content
86
+ */
87
+ export interface ThinkingStep {
88
+ /** Unique identifier for the step */
89
+ id: string;
90
+ /** Label/title shown in the collapsible header */
91
+ label: string;
92
+ /** Content of the thinking step */
93
+ content: string;
94
+ /** Nesting depth (0 = root level, 1 = first indent, etc.) */
95
+ depth: number;
96
+ /** Whether this step should start collapsed (default: false) */
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;
45
104
  }
46
105
 
106
+ /**
107
+ * Union type for thinking content
108
+ * - string: plain text (backward compatible)
109
+ * - ThinkingStep[]: structured with collapsible sub-sections
110
+ */
111
+ export type ThinkingContent = string | ThinkingStep[];
112
+
47
113
  /**
48
114
  * State shape for the agent response component
49
115
  */
@@ -52,14 +118,20 @@ export interface AgentResponseState {
52
118
  status: AgentResponseStatus;
53
119
  /** Accumulated thinking/reasoning text */
54
120
  thinking: string;
121
+ /** Structured thinking steps (if provided, takes precedence over thinking string) */
122
+ thinkingSteps?: ThinkingStep[];
55
123
  /** Tool calls made during processing */
56
124
  toolCalls: ToolCall[];
57
125
  /** Knowledge items retrieved */
58
126
  knowledge: KnowledgeItem[];
59
127
  /** Memory items accessed */
60
128
  memory: MemoryItem[];
129
+ /** Status updates from the agent */
130
+ statusUpdates: StatusItem[];
61
131
  /** Final response text */
62
132
  response: string;
133
+ /** Timeline entries derived from all accumulator arrays (for AgentTimeline) */
134
+ timelineEntries?: TimelineEntry[];
63
135
  /** Timestamp when first thinking message was received (for timer) */
64
136
  thinkingStartTime: number | null;
65
137
  /** Timestamp when response was completed (for final timer display) */
@@ -72,13 +144,21 @@ export interface AgentResponseState {
72
144
  * WebSocket message payload for agent responses
73
145
  */
74
146
  export interface AgentMessage {
75
- type: "status" | "thinking" | "tool_call" | "knowledge" | "memory" | "response";
147
+ type: "status" | "thinking" | "tool_call" | "knowledge" | "memory" | "response" | "status_update";
76
148
  /** Message content - for simple string payloads */
77
149
  message?: string;
78
150
  /** Alternative content field */
79
151
  content?: string;
80
152
  /** For status messages */
81
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;
82
162
  /** For tool_call messages */
83
163
  tool?: {
84
164
  id: string;
@@ -97,6 +177,20 @@ export interface AgentMessage {
97
177
  type: string;
98
178
  content: string;
99
179
  };
180
+ /** For status_update messages */
181
+ statusUpdate?: {
182
+ id: string;
183
+ message: string;
184
+ agent?: string;
185
+ };
186
+ /** For structured thinking step messages */
187
+ thinkingStep?: {
188
+ id?: string;
189
+ label: string;
190
+ content: string;
191
+ depth?: number;
192
+ isCollapsed?: boolean;
193
+ };
100
194
  }
101
195
 
102
196
  /**
@@ -117,6 +211,7 @@ export const initialAgentResponseState: AgentResponseState = {
117
211
  toolCalls: [],
118
212
  knowledge: [],
119
213
  memory: [],
214
+ statusUpdates: [],
120
215
  response: "",
121
216
  thinkingStartTime: null,
122
217
  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={`-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
+ }