@optilogic/chat 1.0.0-beta.6 → 1.0.0-beta.7

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.
@@ -1,33 +1,109 @@
1
1
  /**
2
2
  * Thinking Section Component
3
3
  *
4
- * Collapsible section for displaying agent thinking/reasoning content
4
+ * Collapsible section for displaying agent thinking/reasoning content.
5
+ * Supports both plain text and structured collapsible sub-sections.
5
6
  */
6
7
 
7
8
  import * as React from "react";
9
+ import { useState, useCallback } from "react";
10
+ import { ChevronDown, ChevronRight } from "lucide-react";
8
11
  import { cn } from "@optilogic/core";
12
+ import type { ThinkingStep } from "../types";
9
13
 
10
- export interface ThinkingSectionProps extends React.HTMLAttributes<HTMLDivElement> {
11
- /** The thinking content to display */
12
- content: string;
14
+ export interface ThinkingSectionProps
15
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "content"> {
16
+ /** The thinking content to display (string or structured steps) */
17
+ content: string | ThinkingStep[];
13
18
  /** Whether the section is expanded */
14
19
  isExpanded: boolean;
20
+ /**
21
+ * Custom markdown renderer for the thinking content.
22
+ * If not provided, the content will be rendered as plain preformatted text.
23
+ */
24
+ renderMarkdown?: (content: string) => React.ReactNode;
15
25
  }
16
26
 
27
+ /**
28
+ * Internal component for rendering a collapsible thinking step
29
+ */
30
+ interface ThinkingStepItemProps {
31
+ step: ThinkingStep;
32
+ renderMarkdown?: (content: string) => React.ReactNode;
33
+ }
34
+
35
+ const ThinkingStepItem: React.FC<ThinkingStepItemProps> = ({ step, renderMarkdown }) => {
36
+ const [isCollapsed, setIsCollapsed] = useState(step.isCollapsed ?? false);
37
+
38
+ const toggleCollapse = useCallback(() => {
39
+ setIsCollapsed((prev) => !prev);
40
+ }, []);
41
+
42
+ const indentPadding = step.depth * 16;
43
+
44
+ return (
45
+ <div className="border-b border-border/50 last:border-b-0">
46
+ <button
47
+ onClick={toggleCollapse}
48
+ className="w-full flex items-center gap-1.5 py-1.5 px-2 hover:bg-muted/50 transition-colors text-left"
49
+ style={{ paddingLeft: `${indentPadding + 8}px` }}
50
+ >
51
+ {isCollapsed ? (
52
+ <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
53
+ ) : (
54
+ <ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
55
+ )}
56
+ <span className="text-xs font-medium text-foreground/80">{step.label}</span>
57
+ </button>
58
+
59
+ {!isCollapsed && (
60
+ <div
61
+ className="pb-2 px-2"
62
+ style={{ paddingLeft: `${indentPadding + 28}px` }}
63
+ >
64
+ {renderMarkdown ? (
65
+ <div className="text-xs text-muted-foreground">
66
+ {renderMarkdown(step.content)}
67
+ </div>
68
+ ) : (
69
+ <pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
70
+ {step.content}
71
+ </pre>
72
+ )}
73
+ </div>
74
+ )}
75
+ </div>
76
+ );
77
+ };
78
+
17
79
  /**
18
80
  * ThinkingSection Component
19
81
  *
20
82
  * Displays the agent's thinking/reasoning content in a collapsible panel.
83
+ * Supports both plain text content and structured collapsible sub-sections.
21
84
  *
22
85
  * @example
86
+ * // Plain text content
23
87
  * <ThinkingSection content={state.thinking} isExpanded={isExpanded} />
88
+ *
89
+ * @example
90
+ * // Structured content with sub-sections
91
+ * <ThinkingSection
92
+ * content={[
93
+ * { id: "1", label: "Analysis", content: "...", depth: 0 },
94
+ * { id: "2", label: "Sub-analysis", content: "...", depth: 1 },
95
+ * ]}
96
+ * isExpanded={isExpanded}
97
+ * />
24
98
  */
25
99
  const ThinkingSection = React.forwardRef<HTMLDivElement, ThinkingSectionProps>(
26
- ({ content, isExpanded, className, ...props }, ref) => {
27
- if (!isExpanded || !content) {
100
+ ({ content, isExpanded, renderMarkdown, className, ...props }, ref) => {
101
+ if (!isExpanded || !content || (Array.isArray(content) && content.length === 0)) {
28
102
  return null;
29
103
  }
30
104
 
105
+ const isStructured = Array.isArray(content);
106
+
31
107
  return (
32
108
  <div
33
109
  ref={ref}
@@ -35,9 +111,25 @@ const ThinkingSection = React.forwardRef<HTMLDivElement, ThinkingSectionProps>(
35
111
  {...props}
36
112
  >
37
113
  <div className="mt-2 max-h-[200px] overflow-y-auto">
38
- <pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
39
- {content}
40
- </pre>
114
+ {isStructured ? (
115
+ <div className="space-y-0">
116
+ {content.map((step) => (
117
+ <ThinkingStepItem
118
+ key={step.id}
119
+ step={step}
120
+ renderMarkdown={renderMarkdown}
121
+ />
122
+ ))}
123
+ </div>
124
+ ) : renderMarkdown ? (
125
+ <div className="text-xs text-muted-foreground">
126
+ {renderMarkdown(content)}
127
+ </div>
128
+ ) : (
129
+ <pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
130
+ {content}
131
+ </pre>
132
+ )}
41
133
  </div>
42
134
  </div>
43
135
  );
@@ -13,6 +13,8 @@ import {
13
13
  type ToolCall,
14
14
  type KnowledgeItem,
15
15
  type MemoryItem,
16
+ type StatusItem,
17
+ type ThinkingStep,
16
18
  } from "../types";
17
19
 
18
20
  export interface UseAgentResponseAccumulatorOptions {
@@ -86,6 +88,26 @@ export function useAgentResponseAccumulator(
86
88
  return { ...prev, status: newStatus };
87
89
 
88
90
  case "thinking": {
91
+ // Check if this is a structured thinking step
92
+ if (payload.thinkingStep) {
93
+ const newStep: ThinkingStep = {
94
+ id: payload.thinkingStep.id || `step-${Date.now()}`,
95
+ label: payload.thinkingStep.label,
96
+ content: payload.thinkingStep.content,
97
+ depth: payload.thinkingStep.depth ?? 0,
98
+ isCollapsed: payload.thinkingStep.isCollapsed,
99
+ };
100
+ const thinkingStartTime = prev.thinkingStartTime ?? Date.now();
101
+ return {
102
+ ...prev,
103
+ status: newStatus,
104
+ thinkingSteps: [...(prev.thinkingSteps || []), newStep],
105
+ thinkingStartTime,
106
+ firstMessageTime,
107
+ };
108
+ }
109
+
110
+ // Plain text thinking (existing behavior)
89
111
  const newThinking = payload.message || payload.content || "";
90
112
  // Add line break between thinking messages
91
113
  const separator = prev.thinking && newThinking ? "\n\n" : "";
@@ -170,6 +192,25 @@ export function useAgentResponseAccumulator(
170
192
  firstMessageTime: prev.firstMessageTime ?? Date.now(),
171
193
  };
172
194
 
195
+ case "status_update": {
196
+ const statusMessage = payload.message || payload.statusUpdate?.message;
197
+ if (statusMessage) {
198
+ const newStatusItem: StatusItem = {
199
+ id: payload.statusUpdate?.id || `status-${Date.now()}`,
200
+ message: statusMessage,
201
+ agent: payload.statusUpdate?.agent,
202
+ timestamp: Date.now(),
203
+ };
204
+ return {
205
+ ...prev,
206
+ status: newStatus,
207
+ statusUpdates: [...prev.statusUpdates, newStatusItem],
208
+ firstMessageTime,
209
+ };
210
+ }
211
+ return { ...prev, status: newStatus, firstMessageTime };
212
+ }
213
+
173
214
  default:
174
215
  return { ...prev, status: newStatus, firstMessageTime };
175
216
  }
@@ -38,6 +38,9 @@ export type {
38
38
  ToolCall,
39
39
  KnowledgeItem,
40
40
  MemoryItem,
41
+ StatusItem,
42
+ ThinkingStep,
43
+ ThinkingContent,
41
44
  AgentMessage,
42
45
  GenericWebSocketMessage,
43
46
  } from "./types";
@@ -44,6 +44,40 @@ export interface MemoryItem {
44
44
  timestamp: number;
45
45
  }
46
46
 
47
+ /**
48
+ * Status update information from the agent
49
+ */
50
+ export interface StatusItem {
51
+ id: string;
52
+ message: string;
53
+ timestamp: number;
54
+ /** Optional agent name if in multi-agent scenario */
55
+ agent?: string;
56
+ }
57
+
58
+ /**
59
+ * A single step in structured thinking content
60
+ */
61
+ export interface ThinkingStep {
62
+ /** Unique identifier for the step */
63
+ id: string;
64
+ /** Label/title shown in the collapsible header */
65
+ label: string;
66
+ /** Content of the thinking step */
67
+ content: string;
68
+ /** Nesting depth (0 = root level, 1 = first indent, etc.) */
69
+ depth: number;
70
+ /** Whether this step should start collapsed (default: false) */
71
+ isCollapsed?: boolean;
72
+ }
73
+
74
+ /**
75
+ * Union type for thinking content
76
+ * - string: plain text (backward compatible)
77
+ * - ThinkingStep[]: structured with collapsible sub-sections
78
+ */
79
+ export type ThinkingContent = string | ThinkingStep[];
80
+
47
81
  /**
48
82
  * State shape for the agent response component
49
83
  */
@@ -52,12 +86,16 @@ export interface AgentResponseState {
52
86
  status: AgentResponseStatus;
53
87
  /** Accumulated thinking/reasoning text */
54
88
  thinking: string;
89
+ /** Structured thinking steps (if provided, takes precedence over thinking string) */
90
+ thinkingSteps?: ThinkingStep[];
55
91
  /** Tool calls made during processing */
56
92
  toolCalls: ToolCall[];
57
93
  /** Knowledge items retrieved */
58
94
  knowledge: KnowledgeItem[];
59
95
  /** Memory items accessed */
60
96
  memory: MemoryItem[];
97
+ /** Status updates from the agent */
98
+ statusUpdates: StatusItem[];
61
99
  /** Final response text */
62
100
  response: string;
63
101
  /** Timestamp when first thinking message was received (for timer) */
@@ -72,7 +110,7 @@ export interface AgentResponseState {
72
110
  * WebSocket message payload for agent responses
73
111
  */
74
112
  export interface AgentMessage {
75
- type: "status" | "thinking" | "tool_call" | "knowledge" | "memory" | "response";
113
+ type: "status" | "thinking" | "tool_call" | "knowledge" | "memory" | "response" | "status_update";
76
114
  /** Message content - for simple string payloads */
77
115
  message?: string;
78
116
  /** Alternative content field */
@@ -97,6 +135,20 @@ export interface AgentMessage {
97
135
  type: string;
98
136
  content: string;
99
137
  };
138
+ /** For status_update messages */
139
+ statusUpdate?: {
140
+ id: string;
141
+ message: string;
142
+ agent?: string;
143
+ };
144
+ /** For structured thinking step messages */
145
+ thinkingStep?: {
146
+ id?: string;
147
+ label: string;
148
+ content: string;
149
+ depth?: number;
150
+ isCollapsed?: boolean;
151
+ };
100
152
  }
101
153
 
102
154
  /**
@@ -117,6 +169,7 @@ export const initialAgentResponseState: AgentResponseState = {
117
169
  toolCalls: [],
118
170
  knowledge: [],
119
171
  memory: [],
172
+ statusUpdates: [],
120
173
  response: "",
121
174
  thinkingStartTime: null,
122
175
  responseCompleteTime: null,
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { Send, Loader2 } from "lucide-react";
2
+ import { Send, Loader2, Square } from "lucide-react";
3
3
  import { cn, IconButton } from "@optilogic/core";
4
4
  import {
5
5
  SlateEditor,
@@ -142,6 +142,11 @@ export const UserPromptInput = React.forwardRef<
142
142
  placeholder = "Type your message...",
143
143
  disabled = false,
144
144
  isSubmitting = false,
145
+ onStop,
146
+ disableWhileSubmitting = true,
147
+ autoFocus = false,
148
+ refocusAfterSubmit = false,
149
+ onReady,
145
150
  minRows = 1,
146
151
  maxRows = 6,
147
152
  renderActions,
@@ -155,17 +160,63 @@ export const UserPromptInput = React.forwardRef<
155
160
  ) => {
156
161
  const editorRef = React.useRef<SlateEditorRef>(null);
157
162
  const [internalValue, setInternalValue] = React.useState(value);
163
+ const prevIsSubmitting = React.useRef(isSubmitting);
164
+ const hasEmittedReady = React.useRef(false);
158
165
 
159
166
  // Sync internal value with prop
160
167
  React.useEffect(() => {
161
168
  setInternalValue(value);
162
169
  }, [value]);
163
170
 
171
+ // Handle autoFocus - use double RAF to ensure Slate is fully initialized
172
+ React.useEffect(() => {
173
+ if (autoFocus) {
174
+ requestAnimationFrame(() => {
175
+ requestAnimationFrame(() => {
176
+ editorRef.current?.focus();
177
+ });
178
+ });
179
+ }
180
+ }, [autoFocus]);
181
+
182
+ // Emit onReady callback when editor is initialized
183
+ React.useEffect(() => {
184
+ if (!hasEmittedReady.current && onReady) {
185
+ requestAnimationFrame(() => {
186
+ requestAnimationFrame(() => {
187
+ hasEmittedReady.current = true;
188
+ onReady();
189
+ });
190
+ });
191
+ }
192
+ }, [onReady]);
193
+
194
+ // Refocus after submit completes
195
+ React.useEffect(() => {
196
+ if (refocusAfterSubmit && prevIsSubmitting.current && !isSubmitting) {
197
+ requestAnimationFrame(() => {
198
+ editorRef.current?.focus();
199
+ });
200
+ }
201
+ prevIsSubmitting.current = isSubmitting;
202
+ }, [isSubmitting, refocusAfterSubmit]);
203
+
164
204
  // Expose ref methods
165
205
  React.useImperativeHandle(
166
206
  ref,
167
207
  () => ({
168
- focus: () => editorRef.current?.focus(),
208
+ focus: () => {
209
+ try {
210
+ editorRef.current?.focus();
211
+ } catch {
212
+ // Retry after Slate initializes (handles early calls)
213
+ requestAnimationFrame(() => {
214
+ requestAnimationFrame(() => {
215
+ editorRef.current?.focus();
216
+ });
217
+ });
218
+ }
219
+ },
169
220
  clear: () => {
170
221
  editorRef.current?.clear();
171
222
  setInternalValue("");
@@ -225,7 +276,7 @@ export const UserPromptInput = React.forwardRef<
225
276
  onSubmit={handleSubmit}
226
277
  clearOnSubmit={false}
227
278
  placeholder={placeholder}
228
- disabled={disabled || isSubmitting}
279
+ disabled={disabled || (disableWhileSubmitting && isSubmitting)}
229
280
  enableTags={enableTags}
230
281
  onTagCreate={onTagCreate}
231
282
  onTagDelete={onTagDelete}
@@ -241,21 +292,31 @@ export const UserPromptInput = React.forwardRef<
241
292
  {/* Left actions slot */}
242
293
  <div className="flex items-center gap-1">{renderActions?.()}</div>
243
294
 
244
- {/* Send button */}
245
- <IconButton
246
- icon={
247
- isSubmitting ? (
248
- <Loader2 className="animate-spin" />
249
- ) : (
250
- <Send />
251
- )
252
- }
253
- variant="filled"
254
- size="sm"
255
- aria-label={isSubmitting ? "Sending..." : "Send message"}
256
- disabled={!canSubmit}
257
- onClick={handleSendClick}
258
- />
295
+ {/* Send/Stop button */}
296
+ {isSubmitting && onStop ? (
297
+ <IconButton
298
+ icon={<Square />}
299
+ variant="filled"
300
+ size="sm"
301
+ aria-label="Stop"
302
+ onClick={onStop}
303
+ />
304
+ ) : (
305
+ <IconButton
306
+ icon={
307
+ isSubmitting ? (
308
+ <Loader2 className="animate-spin" />
309
+ ) : (
310
+ <Send />
311
+ )
312
+ }
313
+ variant="filled"
314
+ size="sm"
315
+ aria-label={isSubmitting ? "Sending..." : "Send message"}
316
+ disabled={!canSubmit}
317
+ onClick={handleSendClick}
318
+ />
319
+ )}
259
320
  </div>
260
321
  </div>
261
322
  );
@@ -16,6 +16,16 @@ export interface UserPromptInputProps
16
16
  disabled?: boolean;
17
17
  /** Whether a submission is in progress (shows loading state) */
18
18
  isSubmitting?: boolean;
19
+ /** Called when user clicks Stop during submission */
20
+ onStop?: () => void;
21
+ /** Whether to disable input while submitting (default: true) */
22
+ disableWhileSubmitting?: boolean;
23
+ /** Auto-focus the editor when mounted (handles Slate initialization timing) */
24
+ autoFocus?: boolean;
25
+ /** Refocus the input after submission completes (default: false) */
26
+ refocusAfterSubmit?: boolean;
27
+ /** Called when the editor is fully initialized and ready for interaction */
28
+ onReady?: () => void;
19
29
  /** Minimum number of rows for the editor */
20
30
  minRows?: number;
21
31
  /** Maximum number of rows before scrolling */
package/src/index.ts CHANGED
@@ -34,6 +34,9 @@ export {
34
34
  type ToolCall,
35
35
  type KnowledgeItem,
36
36
  type MemoryItem,
37
+ type StatusItem,
38
+ type ThinkingStep,
39
+ type ThinkingContent,
37
40
  type AgentMessage,
38
41
  type GenericWebSocketMessage,
39
42