@optilogic/chat 1.0.0-beta.8 → 1.0.0-beta.9

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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * HITL Section Component
3
+ *
4
+ * Collapsible section for displaying completed HITL (Human-in-the-Loop)
5
+ * interactions within an AgentResponse. Follows the same pattern as
6
+ * ThinkingSection for consistent UX.
7
+ */
8
+
9
+ import * as React from "react";
10
+ import { useState, useCallback } from "react";
11
+ import { ChevronDown, ChevronRight, MessageCircleQuestion } from "lucide-react";
12
+ import { cn } from "@optilogic/core";
13
+ import { HITLInteractionRecord } from "../../hitl-interactions";
14
+ import type { HITLInteraction } from "../../hitl-interactions";
15
+
16
+ export interface HITLSectionProps
17
+ extends React.HTMLAttributes<HTMLDivElement> {
18
+ /** The HITL interactions to display */
19
+ interactions: HITLInteraction[];
20
+ /** Whether the section starts expanded (uncontrolled) */
21
+ defaultExpanded?: boolean;
22
+ /** Whether the section is expanded (controlled) */
23
+ isExpanded?: boolean;
24
+ /** Callback when expansion state changes (controlled) */
25
+ onExpandedChange?: (expanded: boolean) => void;
26
+ }
27
+
28
+ const HITLSection = React.forwardRef<HTMLDivElement, HITLSectionProps>(
29
+ (
30
+ {
31
+ interactions,
32
+ defaultExpanded = false,
33
+ isExpanded: controlledExpanded,
34
+ onExpandedChange,
35
+ className,
36
+ ...props
37
+ },
38
+ ref
39
+ ) => {
40
+ const [uncontrolledExpanded, setUncontrolledExpanded] =
41
+ useState(defaultExpanded);
42
+
43
+ const isControlled = controlledExpanded !== undefined;
44
+ const isExpanded = isControlled ? controlledExpanded : uncontrolledExpanded;
45
+
46
+ const toggleExpanded = useCallback(() => {
47
+ const newValue = !isExpanded;
48
+ if (isControlled) {
49
+ onExpandedChange?.(newValue);
50
+ } else {
51
+ setUncontrolledExpanded(newValue);
52
+ }
53
+ }, [isExpanded, isControlled, onExpandedChange]);
54
+
55
+ if (!interactions || interactions.length === 0) {
56
+ return null;
57
+ }
58
+
59
+ return (
60
+ <div
61
+ ref={ref}
62
+ className={cn("border-t border-border", className)}
63
+ {...props}
64
+ >
65
+ {/* Collapsible header */}
66
+ <button
67
+ onClick={toggleExpanded}
68
+ className="w-full flex items-center gap-2 py-2 px-3 hover:bg-muted/50 transition-colors text-left"
69
+ >
70
+ {isExpanded ? (
71
+ <ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
72
+ ) : (
73
+ <ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
74
+ )}
75
+ <MessageCircleQuestion className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
76
+ <span className="text-xs font-medium text-foreground/80">
77
+ Clarifying Questions ({interactions.length})
78
+ </span>
79
+ </button>
80
+
81
+ {/* Expanded content */}
82
+ {isExpanded && (
83
+ <div className="px-3 pb-3 space-y-2">
84
+ {interactions.map((interaction, i) => (
85
+ <HITLInteractionRecord key={i} interaction={interaction} />
86
+ ))}
87
+ </div>
88
+ )}
89
+ </div>
90
+ );
91
+ }
92
+ );
93
+ HITLSection.displayName = "HITLSection";
94
+
95
+ export { HITLSection };
@@ -13,3 +13,6 @@ export type { ThinkingSectionProps } from "./ThinkingSection";
13
13
 
14
14
  export { ActionBar } from "./ActionBar";
15
15
  export type { ActionBarProps } from "./ActionBar";
16
+
17
+ export { HITLSection } from "./HITLSection";
18
+ export type { HITLSectionProps } from "./HITLSection";
@@ -15,10 +15,12 @@ export {
15
15
  MetadataRow,
16
16
  ThinkingSection,
17
17
  ActionBar,
18
+ HITLSection,
18
19
  type ActivityIndicatorsProps,
19
20
  type MetadataRowProps,
20
21
  type ThinkingSectionProps,
21
22
  type ActionBarProps,
23
+ type HITLSectionProps,
22
24
  } from "./components";
23
25
 
24
26
  // Hooks
@@ -0,0 +1,139 @@
1
+ /**
2
+ * HITLInteractionRecord — Displays a completed HITL Q&A interaction
3
+ * in the chat message history.
4
+ *
5
+ * Rendered below AgentResponse in the agent message block. Shows the full detail
6
+ * of each clarifying question the agent asked and the user's response.
7
+ */
8
+
9
+ import * as React from "react";
10
+ import { useMemo } from "react";
11
+ import { cn } from "@optilogic/core";
12
+ import type { HITLQuestion } from "./HITLQuestionPanel";
13
+
14
+ export interface HITLInteraction {
15
+ question: HITLQuestion;
16
+ response: string;
17
+ respondedAt: number;
18
+ }
19
+
20
+ export interface HITLInteractionRecordProps
21
+ extends React.HTMLAttributes<HTMLDivElement> {
22
+ interaction: HITLInteraction;
23
+ }
24
+
25
+ /**
26
+ * Parse the formatted response string (produced by buildResponseString) back
27
+ * into a per-question answer map and optional additional context.
28
+ */
29
+ function parseResponse(response: string): {
30
+ answers: Record<string, string>;
31
+ additionalContext: string | null;
32
+ } {
33
+ const answers: Record<string, string> = {};
34
+ let additionalContext: string | null = null;
35
+
36
+ const blocks = response.split("\n\n");
37
+ for (const block of blocks) {
38
+ const qaMatch = block.match(/^Q: (.+)\nA: (.+)$/s);
39
+ if (qaMatch) {
40
+ answers[qaMatch[1].trim()] = qaMatch[2].trim();
41
+ } else if (block.startsWith("Additional context: ")) {
42
+ additionalContext = block.slice("Additional context: ".length).trim();
43
+ }
44
+ }
45
+
46
+ return { answers, additionalContext };
47
+ }
48
+
49
+ const HITLInteractionRecord = React.forwardRef<
50
+ HTMLDivElement,
51
+ HITLInteractionRecordProps
52
+ >(({ interaction, className, ...props }, ref) => {
53
+ const { question, response, respondedAt } = interaction;
54
+ const timestamp = new Date(respondedAt).toLocaleTimeString([], {
55
+ hour: "2-digit",
56
+ minute: "2-digit",
57
+ });
58
+
59
+ const { answers, additionalContext } = useMemo(
60
+ () => parseResponse(response),
61
+ [response]
62
+ );
63
+
64
+ // Check if parsing found any structured answers; if not, fall back to
65
+ // showing the raw response string (for responses not built by buildResponseString).
66
+ const hasParsedAnswers = Object.keys(answers).length > 0;
67
+
68
+ return (
69
+ <div
70
+ ref={ref}
71
+ className={cn(
72
+ "rounded-lg border border-border bg-muted p-3 space-y-2 text-sm",
73
+ className
74
+ )}
75
+ {...props}
76
+ >
77
+ {/* Header */}
78
+ <div className="flex items-center justify-between">
79
+ <span className="font-medium text-muted-foreground">
80
+ Clarifying Question
81
+ </span>
82
+ <span className="text-xs text-muted-foreground">{timestamp}</span>
83
+ </div>
84
+
85
+ {/* Reason */}
86
+ <p className="text-foreground font-medium">{question.reason}</p>
87
+
88
+ {/* Context (if provided) */}
89
+ {question.context && (
90
+ <div className="text-xs text-muted-foreground bg-background rounded p-2 border border-border">
91
+ <pre className="whitespace-pre-wrap font-mono">
92
+ {question.context}
93
+ </pre>
94
+ </div>
95
+ )}
96
+
97
+ {/* Questions with inline answers */}
98
+ <div className="space-y-2">
99
+ {question.questions.map((q, i) => (
100
+ <div key={i}>
101
+ <p className="text-foreground">{q}</p>
102
+ {question.options?.[q] && (
103
+ <p className="text-xs text-muted-foreground ml-2">
104
+ Options: {question.options[q].join(", ")}
105
+ </p>
106
+ )}
107
+ {hasParsedAnswers && answers[q] && (
108
+ <p className="text-xs text-foreground ml-2 mt-0.5 bg-background rounded px-2 py-1 border border-border">
109
+ <span className="text-muted-foreground">Answer: </span>
110
+ {answers[q]}
111
+ </p>
112
+ )}
113
+ </div>
114
+ ))}
115
+ </div>
116
+
117
+ {/* Additional context from freeform text, or raw fallback */}
118
+ {hasParsedAnswers && additionalContext && (
119
+ <div className="border-t border-border pt-2">
120
+ <span className="text-muted-foreground text-xs">
121
+ Additional context:
122
+ </span>
123
+ <p className="text-foreground mt-0.5">{additionalContext}</p>
124
+ </div>
125
+ )}
126
+
127
+ {/* Fallback: show raw response if it wasn't in the parsed Q/A format */}
128
+ {!hasParsedAnswers && (
129
+ <div className="border-t border-border pt-2">
130
+ <span className="text-muted-foreground text-xs">Response:</span>
131
+ <p className="text-foreground mt-0.5">{response}</p>
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ });
137
+ HITLInteractionRecord.displayName = "HITLInteractionRecord";
138
+
139
+ export { HITLInteractionRecord };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * HITLQuestionPanel — Human-in-the-Loop clarifying question panel.
3
+ *
4
+ * Renders in the input area (replacing UserPromptInput) when the agent asks a
5
+ * clarifying question via the HumanInTheLoop tool. Shows the question details
6
+ * and lets the user respond via option buttons or free-form text.
7
+ *
8
+ * Option clicks select/toggle rather than immediately submitting. The "Send
9
+ * response" button enables once all questions have a selected option OR the
10
+ * textarea has text. On submit, selected options and free-form text are
11
+ * combined into a single formatted string for the backend.
12
+ */
13
+
14
+ import * as React from "react";
15
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
16
+ import { cn, Button, Textarea } from "@optilogic/core";
17
+
18
+ export interface HITLQuestion {
19
+ reason: string;
20
+ questions: string[];
21
+ options: Record<string, string[]> | null;
22
+ context: string | null;
23
+ timeoutSeconds: number;
24
+ receivedAt: number;
25
+ }
26
+
27
+ export interface HITLQuestionPanelProps
28
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
29
+ question: HITLQuestion;
30
+ onSubmit: (response: string) => void;
31
+ onStop: () => void;
32
+ }
33
+
34
+ /**
35
+ * Build a single response string from selected options and optional free-form text.
36
+ * Format is designed to be easily parsed by the LLM consuming the response.
37
+ */
38
+ export function buildResponseString(
39
+ questions: string[],
40
+ selectedOptions: Record<string, string>,
41
+ freeformText: string
42
+ ): string {
43
+ const parts: string[] = [];
44
+
45
+ for (const q of questions) {
46
+ const answer = selectedOptions[q];
47
+ if (answer) {
48
+ parts.push(`Q: ${q}\nA: ${answer}`);
49
+ }
50
+ }
51
+
52
+ const trimmed = freeformText.trim();
53
+ if (trimmed) {
54
+ parts.push(`Additional context: ${trimmed}`);
55
+ }
56
+
57
+ return parts.join("\n\n");
58
+ }
59
+
60
+ const HITLQuestionPanel = React.forwardRef<
61
+ HTMLDivElement,
62
+ HITLQuestionPanelProps
63
+ >(({ question, onSubmit, onStop, className, ...props }, ref) => {
64
+ const [freeformText, setFreeformText] = useState("");
65
+ const [selectedOptions, setSelectedOptions] = useState<
66
+ Record<string, string>
67
+ >({});
68
+ const [secondsLeft, setSecondsLeft] = useState(() =>
69
+ Math.max(
70
+ 0,
71
+ Math.round(
72
+ question.timeoutSeconds - (Date.now() - question.receivedAt) / 1000
73
+ )
74
+ )
75
+ );
76
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
77
+
78
+ // Focus the textarea on mount
79
+ useEffect(() => {
80
+ textareaRef.current?.focus();
81
+ }, []);
82
+
83
+ // Countdown timer
84
+ useEffect(() => {
85
+ const interval = setInterval(() => {
86
+ const remaining = Math.max(
87
+ 0,
88
+ Math.round(
89
+ question.timeoutSeconds - (Date.now() - question.receivedAt) / 1000
90
+ )
91
+ );
92
+ setSecondsLeft(remaining);
93
+ if (remaining <= 0) clearInterval(interval);
94
+ }, 1000);
95
+ return () => clearInterval(interval);
96
+ }, [question.timeoutSeconds, question.receivedAt]);
97
+
98
+ const timedOut = secondsLeft <= 0;
99
+
100
+ // Which questions have options defined?
101
+ const questionsWithOptions = useMemo(
102
+ () => question.questions.filter((q) => question.options?.[q]?.length),
103
+ [question.questions, question.options]
104
+ );
105
+
106
+ const allOptionsSelected =
107
+ questionsWithOptions.length > 0 &&
108
+ questionsWithOptions.every((q) => selectedOptions[q]);
109
+
110
+ const hasFreeformText = freeformText.trim().length > 0;
111
+
112
+ const canSubmit = !timedOut && (allOptionsSelected || hasFreeformText);
113
+
114
+ const handleOptionClick = useCallback(
115
+ (questionText: string, option: string) => {
116
+ if (timedOut) return;
117
+ setSelectedOptions((prev) => {
118
+ // Toggle: deselect if already selected, otherwise select
119
+ if (prev[questionText] === option) {
120
+ const next = { ...prev };
121
+ delete next[questionText];
122
+ return next;
123
+ }
124
+ return { ...prev, [questionText]: option };
125
+ });
126
+ },
127
+ [timedOut]
128
+ );
129
+
130
+ const handleSubmit = useCallback(() => {
131
+ if (!canSubmit) return;
132
+ const combined = buildResponseString(
133
+ question.questions,
134
+ selectedOptions,
135
+ freeformText
136
+ );
137
+ onSubmit(combined);
138
+ }, [canSubmit, question.questions, selectedOptions, freeformText, onSubmit]);
139
+
140
+ const handleKeyDown = useCallback(
141
+ (e: React.KeyboardEvent) => {
142
+ if (e.key === "Enter" && !e.shiftKey) {
143
+ e.preventDefault();
144
+ handleSubmit();
145
+ }
146
+ },
147
+ [handleSubmit]
148
+ );
149
+
150
+ const formatTime = (s: number) => {
151
+ const m = Math.floor(s / 60);
152
+ const sec = s % 60;
153
+ return `${m}:${sec.toString().padStart(2, "0")}`;
154
+ };
155
+
156
+ return (
157
+ <div
158
+ ref={ref}
159
+ className={cn(
160
+ "rounded-lg border border-border bg-muted p-4 space-y-3",
161
+ className
162
+ )}
163
+ {...props}
164
+ >
165
+ {/* Header: reason + countdown */}
166
+ <div className="flex items-start justify-between gap-3">
167
+ <div className="flex-1">
168
+ <p className="text-sm font-medium text-foreground">
169
+ {question.reason}
170
+ </p>
171
+ </div>
172
+ <span
173
+ className={cn(
174
+ "text-xs font-mono whitespace-nowrap",
175
+ secondsLeft <= 30 ? "text-destructive" : "text-muted-foreground"
176
+ )}
177
+ >
178
+ {timedOut ? "Timed out" : formatTime(secondsLeft)}
179
+ </span>
180
+ </div>
181
+
182
+ {/* Context (if provided) */}
183
+ {question.context && (
184
+ <div className="text-xs text-muted-foreground bg-background rounded p-2 border border-border max-h-24 overflow-y-auto">
185
+ <pre className="whitespace-pre-wrap font-mono">
186
+ {question.context}
187
+ </pre>
188
+ </div>
189
+ )}
190
+
191
+ {/* Questions */}
192
+ <div className="space-y-3">
193
+ {question.questions.map((q, i) => (
194
+ <div key={i}>
195
+ <p className="text-sm text-foreground">{q}</p>
196
+
197
+ {/* Options for this question (if provided) */}
198
+ {question.options?.[q] && (
199
+ <div className="flex flex-wrap gap-2 mt-1.5">
200
+ {question.options[q].map((option, j) => {
201
+ const isSelected = selectedOptions[q] === option;
202
+ return (
203
+ <Button
204
+ key={j}
205
+ variant={isSelected ? "primary" : "outline"}
206
+ size="sm"
207
+ onClick={() => handleOptionClick(q, option)}
208
+ disabled={timedOut}
209
+ >
210
+ {option}
211
+ </Button>
212
+ );
213
+ })}
214
+ </div>
215
+ )}
216
+ </div>
217
+ ))}
218
+ </div>
219
+
220
+ {/* Free-form input — always shown for additional context */}
221
+ <Textarea
222
+ ref={textareaRef}
223
+ value={freeformText}
224
+ onChange={(e) => setFreeformText(e.target.value)}
225
+ onKeyDown={handleKeyDown}
226
+ disabled={timedOut}
227
+ placeholder="Add additional context or type a full response..."
228
+ rows={2}
229
+ className="resize-none"
230
+ />
231
+
232
+ <div className="flex items-center justify-between">
233
+ <Button
234
+ variant="ghost"
235
+ size="sm"
236
+ onClick={onStop}
237
+ className="text-destructive hover:text-destructive hover:bg-destructive/10"
238
+ >
239
+ Stop agent
240
+ </Button>
241
+
242
+ <Button
243
+ variant="primary"
244
+ size="sm"
245
+ onClick={handleSubmit}
246
+ disabled={!canSubmit}
247
+ >
248
+ Send response
249
+ </Button>
250
+ </div>
251
+ </div>
252
+ );
253
+ });
254
+ HITLQuestionPanel.displayName = "HITLQuestionPanel";
255
+
256
+ export { HITLQuestionPanel };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * HITL (Human-in-the-Loop) Interaction Components
3
+ *
4
+ * Components for displaying and handling clarifying questions
5
+ * between the AI agent and the user during a conversation.
6
+ */
7
+
8
+ // Question panel (interactive input area)
9
+ export { HITLQuestionPanel } from "./HITLQuestionPanel";
10
+ export type { HITLQuestionPanelProps, HITLQuestion } from "./HITLQuestionPanel";
11
+ export { buildResponseString } from "./HITLQuestionPanel";
12
+
13
+ // Interaction record (read-only history display)
14
+ export { HITLInteractionRecord } from "./HITLInteractionRecord";
15
+ export type {
16
+ HITLInteractionRecordProps,
17
+ HITLInteraction,
18
+ } from "./HITLInteractionRecord";
package/src/index.ts CHANGED
@@ -15,10 +15,12 @@ export {
15
15
  MetadataRow,
16
16
  ThinkingSection,
17
17
  ActionBar,
18
+ HITLSection,
18
19
  type ActivityIndicatorsProps,
19
20
  type MetadataRowProps,
20
21
  type ThinkingSectionProps,
21
22
  type ActionBarProps,
23
+ type HITLSectionProps,
22
24
 
23
25
  // Hooks
24
26
  useAgentResponseAccumulator,
@@ -57,3 +59,14 @@ export {
57
59
  type UserPromptInputProps,
58
60
  type UserPromptInputRef,
59
61
  } from "./components/user-prompt-input";
62
+
63
+ // HITL Interactions - Human-in-the-loop question/response components
64
+ export {
65
+ HITLQuestionPanel,
66
+ type HITLQuestionPanelProps,
67
+ HITLInteractionRecord,
68
+ type HITLInteractionRecordProps,
69
+ type HITLQuestion,
70
+ type HITLInteraction,
71
+ buildResponseString,
72
+ } from "./components/hitl-interactions";