@optilogic/chat 1.0.0-beta.10 → 1.0.0-beta.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optilogic/chat",
3
- "version": "1.0.0-beta.10",
3
+ "version": "1.0.0-beta.12",
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.10",
28
- "@optilogic/editor": "1.0.0-beta.10"
27
+ "@optilogic/core": "1.0.0-beta.12",
28
+ "@optilogic/editor": "1.0.0-beta.12"
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 structured)
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 - collapsible with max-height */}
249
- <ThinkingSection
250
- content={
251
- state.thinkingSteps && state.thinkingSteps.length > 0
252
- ? state.thinkingSteps
253
- : state.thinking
254
- }
255
- isExpanded={thinkingExpanded}
256
- renderMarkdown={renderThinkingMarkdown}
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) => (
@@ -15,7 +15,9 @@ import {
15
15
  type MemoryItem,
16
16
  type StatusItem,
17
17
  type ThinkingStep,
18
+ type PotentialResponse,
18
19
  } from "../types";
20
+ import { buildTimelineEntries } from "../../agent-timeline/utils";
19
21
 
20
22
  export interface UseAgentResponseAccumulatorOptions {
21
23
  /** WebSocket topic to filter messages (optional, for convenience) */
@@ -94,33 +96,50 @@ export function useAgentResponseAccumulator(
94
96
  id: payload.thinkingStep.id || `step-${Date.now()}`,
95
97
  label: payload.thinkingStep.label,
96
98
  content: payload.thinkingStep.content,
97
- depth: payload.thinkingStep.depth ?? 0,
99
+ depth: payload.thinkingStep.depth ?? payload.depth ?? 0,
98
100
  isCollapsed: payload.thinkingStep.isCollapsed,
101
+ timestamp: Date.now(),
102
+ agentName: payload.agentName,
103
+ parentAgent: payload.parentAgent,
99
104
  };
100
105
  const thinkingStartTime = prev.thinkingStartTime ?? Date.now();
101
- return {
106
+ const next = {
102
107
  ...prev,
103
108
  status: newStatus,
104
109
  thinkingSteps: [...(prev.thinkingSteps || []), newStep],
105
110
  thinkingStartTime,
106
111
  firstMessageTime,
107
112
  };
113
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
108
114
  }
109
115
 
110
- // Plain text thinking (existing behavior)
116
+ // Plain text thinking concatenate for backward compat AND
117
+ // push a ThinkingStep so the timeline gets individual entries.
111
118
  const newThinking = payload.message || payload.content || "";
112
119
  // Add line break between thinking messages
113
120
  const separator = prev.thinking && newThinking ? "\n\n" : "";
114
121
  // Set thinkingStartTime on first thinking message
115
122
  const thinkingStartTime =
116
123
  prev.thinkingStartTime ?? (newThinking ? Date.now() : null);
117
- return {
124
+ const prevSteps = prev.thinkingSteps || [];
125
+ const plainStep: ThinkingStep = {
126
+ id: `step-${prevSteps.length}`,
127
+ label: newThinking,
128
+ content: newThinking,
129
+ depth: payload.depth ?? 0,
130
+ timestamp: Date.now(),
131
+ agentName: payload.agentName,
132
+ parentAgent: payload.parentAgent,
133
+ };
134
+ const next = {
118
135
  ...prev,
119
136
  status: newStatus,
120
137
  thinking: prev.thinking + separator + newThinking,
138
+ thinkingSteps: [...prevSteps, plainStep],
121
139
  thinkingStartTime,
122
140
  firstMessageTime,
123
141
  };
142
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
124
143
  }
125
144
 
126
145
  case "tool_call": {
@@ -132,13 +151,17 @@ export function useAgentResponseAccumulator(
132
151
  name: toolName,
133
152
  arguments: payload.tool?.arguments,
134
153
  timestamp: Date.now(),
154
+ agentName: payload.agentName,
155
+ parentAgent: payload.parentAgent,
156
+ depth: payload.depth,
135
157
  };
136
- return {
158
+ const next = {
137
159
  ...prev,
138
160
  status: newStatus,
139
161
  toolCalls: [...prev.toolCalls, newToolCall],
140
162
  firstMessageTime,
141
163
  };
164
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
142
165
  }
143
166
  return { ...prev, status: newStatus, firstMessageTime };
144
167
  }
@@ -152,13 +175,17 @@ export function useAgentResponseAccumulator(
152
175
  source: payload.knowledge?.source || "unknown",
153
176
  content: knowledgeContent,
154
177
  timestamp: Date.now(),
178
+ agentName: payload.agentName,
179
+ parentAgent: payload.parentAgent,
180
+ depth: payload.depth,
155
181
  };
156
- return {
182
+ const next = {
157
183
  ...prev,
158
184
  status: newStatus,
159
185
  knowledge: [...prev.knowledge, newKnowledge],
160
186
  firstMessageTime,
161
187
  };
188
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
162
189
  }
163
190
  return { ...prev, status: newStatus, firstMessageTime };
164
191
  }
@@ -172,13 +199,17 @@ export function useAgentResponseAccumulator(
172
199
  type: payload.memory?.type || "unknown",
173
200
  content: memoryContent,
174
201
  timestamp: Date.now(),
202
+ agentName: payload.agentName,
203
+ parentAgent: payload.parentAgent,
204
+ depth: payload.depth,
175
205
  };
176
- return {
206
+ const next = {
177
207
  ...prev,
178
208
  status: newStatus,
179
209
  memory: [...prev.memory, newMemory],
180
210
  firstMessageTime,
181
211
  };
212
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
182
213
  }
183
214
  return { ...prev, status: newStatus, firstMessageTime };
184
215
  }
@@ -200,13 +231,39 @@ export function useAgentResponseAccumulator(
200
231
  message: statusMessage,
201
232
  agent: payload.statusUpdate?.agent,
202
233
  timestamp: Date.now(),
234
+ agentName: payload.agentName,
235
+ parentAgent: payload.parentAgent,
236
+ depth: payload.depth,
203
237
  };
204
- return {
238
+ const next = {
205
239
  ...prev,
206
240
  status: newStatus,
207
241
  statusUpdates: [...prev.statusUpdates, newStatusItem],
208
242
  firstMessageTime,
209
243
  };
244
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
245
+ }
246
+ return { ...prev, status: newStatus, firstMessageTime };
247
+ }
248
+
249
+ case "potential_response": {
250
+ const respContent = payload.message || payload.content || "";
251
+ if (respContent) {
252
+ const newResp: PotentialResponse = {
253
+ id: `resp-${Date.now()}`,
254
+ content: respContent,
255
+ timestamp: Date.now(),
256
+ agentName: payload.agentName,
257
+ parentAgent: payload.parentAgent,
258
+ depth: payload.depth,
259
+ };
260
+ const next = {
261
+ ...prev,
262
+ status: newStatus,
263
+ potentialResponses: [...(prev.potentialResponses || []), newResp],
264
+ firstMessageTime,
265
+ };
266
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
210
267
  }
211
268
  return { ...prev, status: newStatus, firstMessageTime };
212
269
  }
@@ -45,6 +45,7 @@ export type {
45
45
  StatusItem,
46
46
  ThinkingStep,
47
47
  ThinkingContent,
48
+ PotentialResponse,
48
49
  AgentMessage,
49
50
  GenericWebSocketMessage,
50
51
  } from "./types";
@@ -53,3 +54,19 @@ export { initialAgentResponseState } from "./types";
53
54
 
54
55
  // Utilities
55
56
  export { formatTime, formatTotalTime } from "./utils";
57
+
58
+ // Agent Timeline (replaces ThinkingSection for rich execution visibility)
59
+ export {
60
+ AgentTimeline,
61
+ createTimelineUIState,
62
+ buildTimelineEntries,
63
+ groupIntoAgentRuns,
64
+ deduplicateEntries,
65
+ } from "../agent-timeline";
66
+ export type {
67
+ TimelineUIState,
68
+ TimelineEntry,
69
+ TimelineEntryType,
70
+ AgentRun,
71
+ DisplayEntry,
72
+ } 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,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={`-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
+ }