@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
@@ -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
+ }
@@ -0,0 +1,167 @@
1
+ import type { TimelineEntry, AgentRun, DisplayEntry } from "./types";
2
+ import type { AgentResponseState } from "../agent-response/types";
3
+
4
+ /**
5
+ * Build a flat, chronologically sorted array of TimelineEntry from the
6
+ * accumulator state. Maps each typed array (toolCalls, knowledge, etc.)
7
+ * to unified TimelineEntry objects with per-source stable IDs.
8
+ */
9
+ export function buildTimelineEntries(state: AgentResponseState): TimelineEntry[] {
10
+ const entries: TimelineEntry[] = [];
11
+
12
+ // Thinking steps (structured)
13
+ if (state.thinkingSteps) {
14
+ let idx = 0;
15
+ for (const step of state.thinkingSteps) {
16
+ entries.push({
17
+ id: `tl-think-${idx++}`,
18
+ type: "thinking",
19
+ agentName: step.agentName ?? null,
20
+ parentAgent: step.parentAgent ?? null,
21
+ depth: step.depth ?? 0,
22
+ content: step.content,
23
+ title: step.label,
24
+ timestamp: step.timestamp ?? 0,
25
+ });
26
+ }
27
+ } else if (state.thinking) {
28
+ // Plain text thinking (legacy) — single entry
29
+ entries.push({
30
+ id: "tl-think-0",
31
+ type: "thinking",
32
+ agentName: null,
33
+ parentAgent: null,
34
+ depth: 0,
35
+ content: state.thinking,
36
+ title: null,
37
+ timestamp: state.thinkingStartTime ?? 0,
38
+ });
39
+ }
40
+
41
+ // Tool calls
42
+ let toolIdx = 0;
43
+ for (const tool of state.toolCalls) {
44
+ entries.push({
45
+ id: `tl-tool-${toolIdx++}`,
46
+ type: "tool_call",
47
+ agentName: tool.agentName ?? null,
48
+ parentAgent: tool.parentAgent ?? null,
49
+ depth: tool.depth ?? 0,
50
+ content: tool.name,
51
+ title: null,
52
+ timestamp: tool.timestamp,
53
+ });
54
+ }
55
+
56
+ // Knowledge items
57
+ let knowIdx = 0;
58
+ for (const item of state.knowledge) {
59
+ entries.push({
60
+ id: `tl-know-${knowIdx++}`,
61
+ type: "knowledge",
62
+ agentName: item.agentName ?? null,
63
+ parentAgent: item.parentAgent ?? null,
64
+ depth: item.depth ?? 0,
65
+ content: item.content,
66
+ title: item.source,
67
+ timestamp: item.timestamp,
68
+ });
69
+ }
70
+
71
+ // Memory items
72
+ let memIdx = 0;
73
+ for (const item of state.memory) {
74
+ entries.push({
75
+ id: `tl-mem-${memIdx++}`,
76
+ type: "memory",
77
+ agentName: item.agentName ?? null,
78
+ parentAgent: item.parentAgent ?? null,
79
+ depth: item.depth ?? 0,
80
+ content: item.content,
81
+ title: item.type,
82
+ timestamp: item.timestamp,
83
+ });
84
+ }
85
+
86
+ // Status updates
87
+ let statIdx = 0;
88
+ for (const item of state.statusUpdates) {
89
+ entries.push({
90
+ id: `tl-stat-${statIdx++}`,
91
+ type: "status_update",
92
+ agentName: item.agentName ?? item.agent ?? null,
93
+ parentAgent: item.parentAgent ?? null,
94
+ depth: item.depth ?? 0,
95
+ content: item.message,
96
+ title: null,
97
+ timestamp: item.timestamp,
98
+ });
99
+ }
100
+
101
+ // Sort chronologically
102
+ entries.sort((a, b) => a.timestamp - b.timestamp);
103
+
104
+ return entries;
105
+ }
106
+
107
+ /**
108
+ * Group a sorted array of timeline entries into consecutive "agent runs".
109
+ * A new run starts whenever the agentName changes.
110
+ * Each run's entries are deduplicated.
111
+ */
112
+ export function groupIntoAgentRuns(entries: TimelineEntry[]): AgentRun[] {
113
+ const runs: AgentRun[] = [];
114
+ let currentRun: AgentRun | null = null;
115
+
116
+ for (const entry of entries) {
117
+ const name = entry.agentName || "Agent";
118
+
119
+ if (!currentRun || currentRun.agentName !== name) {
120
+ // Start a new run
121
+ currentRun = {
122
+ agentName: name,
123
+ parentAgent: entry.parentAgent,
124
+ depth: entry.depth,
125
+ entries: [],
126
+ };
127
+ runs.push(currentRun);
128
+ }
129
+
130
+ currentRun.entries.push({ entry, count: 1 });
131
+ }
132
+
133
+ // Deduplicate within each run
134
+ for (const run of runs) {
135
+ run.entries = deduplicateEntries(run.entries);
136
+ }
137
+
138
+ return runs;
139
+ }
140
+
141
+ /**
142
+ * Collapse consecutive entries with identical type AND content into
143
+ * a single DisplayEntry with count > 1.
144
+ * Handles patterns like "Loading documents" appearing 8 times.
145
+ */
146
+ export function deduplicateEntries(entries: DisplayEntry[]): DisplayEntry[] {
147
+ if (entries.length === 0) return [];
148
+
149
+ const result: DisplayEntry[] = [entries[0]];
150
+
151
+ for (let i = 1; i < entries.length; i++) {
152
+ const prev = result[result.length - 1];
153
+ const curr = entries[i];
154
+
155
+ if (
156
+ prev.entry.type === curr.entry.type &&
157
+ prev.entry.content === curr.entry.content
158
+ ) {
159
+ // Merge into previous
160
+ prev.count += curr.count;
161
+ } else {
162
+ result.push({ ...curr });
163
+ }
164
+ }
165
+
166
+ return result;
167
+ }
@@ -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 };