orchid-ai 2.1.4 → 2.2.0

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,4 +1,4 @@
1
- import React, { useRef, useEffect } from 'react';
1
+ import React, { useRef, useEffect, useCallback } from 'react';
2
2
  import Message from './Message';
3
3
 
4
4
  const DEFAULT_SUGGESTIONS = [
@@ -14,6 +14,9 @@ export default function ChatWindow({
14
14
  messages,
15
15
  loading,
16
16
  statusText,
17
+ queuedMessages,
18
+ onCancelQueue,
19
+ onStop,
17
20
  onSuggestionClick,
18
21
  aiEnabled,
19
22
  organisationName,
@@ -27,9 +30,40 @@ export default function ChatWindow({
27
30
  }) {
28
31
  const exportPrefix = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
29
32
  const bottomRef = useRef(null);
33
+ const scrollerRef = useRef(null);
34
+ const stickToBottomRef = useRef(true);
35
+ const prevLoadingRef = useRef(false);
30
36
 
37
+ // Track whether the user has scrolled away from the bottom. Only update on actual
38
+ // scroll events — not on content growth — so appending text never disengages sticky.
39
+ const handleScroll = useCallback(() => {
40
+ const el = scrollerRef.current;
41
+ if (!el) return;
42
+ stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
43
+ }, []);
44
+
45
+ // Re-engage sticky scroll whenever a new request starts so the user always sees
46
+ // their message and the incoming response, even if they had scrolled up.
31
47
  useEffect(() => {
32
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
48
+ if (loading && !prevLoadingRef.current) {
49
+ stickToBottomRef.current = true;
50
+ }
51
+ prevLoadingRef.current = loading;
52
+ }, [loading]);
53
+
54
+ // Pin the viewport to the bottom on every streaming delta. Direct scrollTop
55
+ // assignment is instant so the bubble always extends naturally from the bottom
56
+ // of the screen with no lag or chasing. A single smooth scroll runs when
57
+ // streaming ends to settle the final render.
58
+ useEffect(() => {
59
+ if (!stickToBottomRef.current) return;
60
+ const el = scrollerRef.current;
61
+ if (!el) return;
62
+ if (loading) {
63
+ el.scrollTop = el.scrollHeight - el.clientHeight;
64
+ } else {
65
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
66
+ }
33
67
  }, [messages, loading, statusText]);
34
68
 
35
69
  const renderEmptyState = () => {
@@ -106,7 +140,7 @@ export default function ChatWindow({
106
140
  const hasStreamingMessage = lastMsg?.isStreaming === true;
107
141
 
108
142
  return (
109
- <div className="ai-chat-window">
143
+ <div className="ai-chat-window" ref={scrollerRef} onScroll={handleScroll}>
110
144
  {messages?.length === 0 && !loading && renderEmptyState()}
111
145
  {(messages ?? []).map((msg, i) => {
112
146
  const isLast = i === (messages?.length ?? 0) - 1;
@@ -120,12 +154,14 @@ export default function ChatWindow({
120
154
  truncated={msg.truncated}
121
155
  exportPrefix={exportPrefix}
122
156
  isStreaming={msg.isStreaming}
157
+ stopped={msg.stopped}
123
158
  streamingStatusText={streamingStatusText}
124
159
  processTrace={msg.processTrace}
125
160
  processInterimLive={msg.processInterimLive}
126
161
  showProcessTracePanel={showProcessTracePanel}
127
162
  queryContext={msg.queryContext}
128
163
  showQuerySummary={showQuerySummary}
164
+ attachments={msg.attachments}
129
165
  />
130
166
  );
131
167
  })}
@@ -146,6 +182,29 @@ export default function ChatWindow({
146
182
  </div>
147
183
  </div>
148
184
  )}
185
+ {(queuedMessages ?? []).map((msg, i) => (
186
+ <div key={i} className="ai-chat-message user ai-chat-message--queued">
187
+ <div className="ai-chat-avatar user">YOU</div>
188
+ <div className="ai-chat-bubble user ai-chat-bubble--queued">
189
+ <div className="ai-chat-pending-msg-text">{msg}</div>
190
+ <div className="ai-chat-pending-footer">
191
+ <span className="ai-chat-pending-status">Queued</span>
192
+ <div className="ai-chat-pending-actions">
193
+ {onCancelQueue && (
194
+ <button type="button" className="ai-chat-pending-btn" onClick={() => onCancelQueue(i)}>
195
+ Cancel
196
+ </button>
197
+ )}
198
+ {i === 0 && onStop && (
199
+ <button type="button" className="ai-chat-pending-btn ai-chat-pending-btn--primary" onClick={onStop}>
200
+ Send now
201
+ </button>
202
+ )}
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ ))}
149
208
  <div ref={bottomRef} />
150
209
  </div>
151
210
  );
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState, useMemo, useEffect } from "react";
1
+ import React, { useRef, useState, useMemo, useEffect, useLayoutEffect } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import AiVisualization from "./visualizations/AiVisualization";
@@ -46,6 +46,72 @@ function getBlockLabel(lang) {
46
46
  return `Generating ${lang}`;
47
47
  }
48
48
 
49
+ /**
50
+ * Split fully-closed orchid-ai-chart / hemiq-chart fences out of the content string
51
+ * so they can be rendered as stable memoized components. Text between/around charts
52
+ * becomes separate ReactMarkdown segments. Keys are based on character position so
53
+ * they stay stable as trailing text streams in.
54
+ *
55
+ * Returns an array of { type: 'text'|'chart', content|language+rawValue, key }.
56
+ */
57
+ const COMPLETE_CHART_FENCE_RE = /```(orchid-ai-chart|hemiq-chart)\r?\n([\s\S]*?)```/g;
58
+
59
+ function splitContentSegments(content) {
60
+ const segments = [];
61
+ let lastIndex = 0;
62
+ const re = new RegExp(COMPLETE_CHART_FENCE_RE.source, 'g');
63
+ let match;
64
+ while ((match = re.exec(content)) !== null) {
65
+ if (match.index > lastIndex) {
66
+ segments.push({ type: 'text', content: content.slice(lastIndex, match.index), key: `t${lastIndex}` });
67
+ }
68
+ segments.push({
69
+ type: 'chart',
70
+ language: match[1],
71
+ rawValue: match[2].replace(/\n$/, ''),
72
+ key: `c${match.index}`,
73
+ });
74
+ lastIndex = match.index + match[0].length;
75
+ }
76
+ if (lastIndex < content.length) {
77
+ segments.push({ type: 'text', content: content.slice(lastIndex), key: `t${lastIndex}` });
78
+ }
79
+ return segments;
80
+ }
81
+
82
+ /**
83
+ * Renders a completed chart block. Wrapped in React.memo so it only re-renders
84
+ * when the chart JSON actually changes — not on every streaming delta.
85
+ */
86
+ const MemoizedChartBlock = React.memo(function MemoizedChartBlock({ language, rawValue }) {
87
+ const chartResult = resolveChartBlock(language, rawValue);
88
+ if (chartResult.type === "chart") {
89
+ return <AiVisualization chart={chartResult.chart} />;
90
+ }
91
+ if (chartResult.type === "invalid_chart_code") {
92
+ const cutOff = chartResult.error?.includes("JSON");
93
+ return (
94
+ <div className="ai-chart-card ai-chart-error">
95
+ <p>
96
+ {cutOff
97
+ ? "Visualization could not be rendered — the response was cut off. Try asking again or request fewer records."
98
+ : "Visualization could not be rendered — the AI used an unsupported format. Try rephrasing your request."}
99
+ </p>
100
+ {IS_DEV && (
101
+ <details className="ai-chart-debug-details">
102
+ <summary>Debug (development only)</summary>
103
+ <pre className="ai-chart-debug-pre">
104
+ {chartResult.error ? `Validator: ${chartResult.error}\n\n` : ""}
105
+ {`--- raw ${language} fence ---\n${rawValue}`}
106
+ </pre>
107
+ </details>
108
+ )}
109
+ </div>
110
+ );
111
+ }
112
+ return null;
113
+ });
114
+
49
115
  const UPPER_ABBREV = new Set(['id', 'url', 'api', 'uuid', 'ip', 'sku', 'po', 'eta', 'ref']);
50
116
 
51
117
  function camelToTitleCase(str) {
@@ -154,6 +220,39 @@ function formatToolName(tool) {
154
220
  .replace(/\b\w/g, (c) => c.toUpperCase());
155
221
  }
156
222
 
223
+ function urlHost(url) {
224
+ try { return new URL(url).hostname; } catch { return url; }
225
+ }
226
+
227
+ function renderQueryParam(value) {
228
+ if (Array.isArray(value)) {
229
+ const items = value.map(String).filter(Boolean);
230
+ if (items.length && items.every((v) => /^https?:\/\//i.test(v))) {
231
+ return (
232
+ <ul className="ai-chat-process-url-list">
233
+ {items.map((url, i) => (
234
+ <li key={i}>
235
+ <a href={url} title={url} target="_blank" rel="noopener noreferrer" className="ai-chat-process-url">
236
+ {urlHost(url)}
237
+ </a>
238
+ </li>
239
+ ))}
240
+ </ul>
241
+ );
242
+ }
243
+ return formatQueryValue(value);
244
+ }
245
+ const s = String(value ?? '');
246
+ if (/^https?:\/\//i.test(s)) {
247
+ return (
248
+ <a href={s} title={s} target="_blank" rel="noopener noreferrer" className="ai-chat-process-url">
249
+ {urlHost(s)}
250
+ </a>
251
+ );
252
+ }
253
+ return formatQueryValue(value);
254
+ }
255
+
157
256
  function QueryStep({ entry }) {
158
257
  const params = Object.entries(entry.input || {}).filter(([, v]) => v !== null && v !== undefined && v !== '');
159
258
  return (
@@ -168,7 +267,7 @@ function QueryStep({ entry }) {
168
267
  {params.map(([key, value]) => (
169
268
  <div key={key} className="ai-chat-process-trace__query-param">
170
269
  <dt>{camelToTitleCase(key)}</dt>
171
- <dd>{formatQueryValue(value)}</dd>
270
+ <dd>{renderQueryParam(value)}</dd>
172
271
  </div>
173
272
  ))}
174
273
  </dl>
@@ -257,21 +356,79 @@ function ProcessTracePanel({ processTrace, processInterimLive, isStreaming, show
257
356
  );
258
357
  }
259
358
 
359
+ function AttachmentFileIcon() {
360
+ return (
361
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
362
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
363
+ <polyline points="13 2 13 9 20 9" />
364
+ </svg>
365
+ );
366
+ }
367
+
260
368
  export default function Message({
261
369
  role,
262
370
  content,
263
371
  truncated,
264
372
  exportPrefix = "orchid-ai",
265
373
  isStreaming = false,
374
+ stopped = false,
266
375
  streamingStatusText,
267
376
  processTrace,
268
377
  processInterimLive = "",
269
378
  showProcessTracePanel = true,
379
+ queryContext,
380
+ showQuerySummary = false,
381
+ attachments,
270
382
  }) {
271
383
  const isUser = role === "user";
272
384
  const [copied, setCopied] = useState(false);
273
385
  const [isPrinting, setIsPrinting] = useState(false);
274
386
  const messageRef = useRef(null);
387
+ const bubbleRef = useRef(null);
388
+ const prevBubbleHeightRef = useRef(null); // height after the previous paint
389
+ const pendingRevealRef = useRef(0); // accumulated clip-path reveal not yet finished (px)
390
+
391
+ // Record bubble height AFTER each paint so the next useLayoutEffect can detect line wraps.
392
+ useEffect(() => {
393
+ if (isUser || !isStreaming) {
394
+ prevBubbleHeightRef.current = null;
395
+ pendingRevealRef.current = 0;
396
+ return;
397
+ }
398
+ const el = bubbleRef.current;
399
+ if (!el) return;
400
+ prevBubbleHeightRef.current = el.scrollHeight;
401
+ // Reset the accumulator when the clip-path transition finishes naturally.
402
+ const onEnd = (e) => {
403
+ if (e.propertyName === 'clip-path') pendingRevealRef.current = 0;
404
+ };
405
+ el.addEventListener('transitionend', onEnd, { once: true });
406
+ return () => el.removeEventListener('transitionend', onEnd);
407
+ }, [content, isStreaming, isUser]);
408
+
409
+ // Reveal new line-wrap content via clip-path BEFORE each paint.
410
+ // clip-path is layout-neutral — it never changes the bubble's layout height,
411
+ // so scrollHeight / scrollTop stay correct and the scroll stays pinned.
412
+ useLayoutEffect(() => {
413
+ if (isUser) return;
414
+ const el = bubbleRef.current;
415
+ if (!el) return;
416
+ if (!isStreaming) {
417
+ el.style.clipPath = '';
418
+ el.style.transition = '';
419
+ return;
420
+ }
421
+ const newHeight = el.scrollHeight;
422
+ const prevHeight = prevBubbleHeightRef.current;
423
+ if (prevHeight !== null && newHeight > prevHeight + 2) {
424
+ pendingRevealRef.current += newHeight - prevHeight;
425
+ el.style.transition = 'none';
426
+ el.style.clipPath = `inset(0 0 ${pendingRevealRef.current}px 0)`;
427
+ el.offsetHeight; // force reflow so transition: none takes effect
428
+ el.style.transition = 'clip-path 0.12s ease-out';
429
+ el.style.clipPath = 'inset(0 0 0px 0)';
430
+ }
431
+ }, [content, isStreaming, isUser]);
275
432
 
276
433
  // Extract AI-provided title comment and strip it from rendered content
277
434
  const { responseTitle, renderContent } = useMemo(
@@ -282,7 +439,12 @@ export default function Message({
282
439
  // When streaming, detect an unclosed code block so we can show a labeled placeholder
283
440
  // instead of passing a broken fence to ReactMarkdown.
284
441
  const streamingPrefix = !isUser && isStreaming ? getStreamingPrefix(renderContent) : null;
285
- const openBlockLabel = streamingPrefix !== null ? getBlockLabel(getOpenBlockLanguage(renderContent)) : null;
442
+ // When stopped mid-generation, detect any unclosed chart fence so we can show a
443
+ // static "Rendering stopped" block instead of the animated placeholder.
444
+ const stoppedPrefix = !isUser && stopped && !isStreaming ? getStreamingPrefix(renderContent) : null;
445
+ const openBlockLabel = (streamingPrefix !== null || stoppedPrefix !== null)
446
+ ? getBlockLabel(getOpenBlockLanguage(renderContent))
447
+ : null;
286
448
 
287
449
  const handleCopy = () => {
288
450
  const stripChartBlocks = (value) =>
@@ -370,7 +532,9 @@ export default function Message({
370
532
  showProcessTracePanel !== false &&
371
533
  orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, { isStreaming });
372
534
 
373
- const markdownComponents = {
535
+ // Stable reference — none of these renderers close over changing component state,
536
+ // so a single memoized object prevents ReactMarkdown from re-rendering on every delta.
537
+ const markdownComponents = useMemo(() => ({
374
538
  pre({ children, ...props }) {
375
539
  const onlyChild = React.Children.toArray(children)[0];
376
540
  const className = onlyChild?.props?.className || "";
@@ -431,14 +595,21 @@ export default function Message({
431
595
  </a>
432
596
  );
433
597
  },
434
- };
598
+ }), []);
599
+
600
+ // Split completed chart blocks into their own memoized components so streaming
601
+ // text after a chart doesn't cause already-rendered visualizations to redraw.
602
+ const contentSegments = useMemo(
603
+ () => splitContentSegments(renderContent),
604
+ [renderContent]
605
+ );
435
606
 
436
607
  return (
437
608
  <div className={`ai-chat-message ${role}`} ref={messageRef}>
438
609
  <div className={`ai-chat-avatar ${role}`}>
439
610
  {isUser ? "You" : "AI"}
440
611
  </div>
441
- <div className={`ai-chat-bubble ${role}`}>
612
+ <div className={`ai-chat-bubble ${role}`} ref={bubbleRef}>
442
613
  <div className="ai-chat-message-content">
443
614
  {!isUser ? (
444
615
  <ProcessTracePanel
@@ -459,7 +630,18 @@ export default function Message({
459
630
  </div>
460
631
  ) : null}
461
632
  {isUser ? (
462
- <UserBubbleContent content={content} />
633
+ <>
634
+ {attachments?.length > 0 && (
635
+ <div className="ai-chat-user-attachments">
636
+ {attachments.map((att, i) => (
637
+ att.preview
638
+ ? <img key={i} src={att.preview} alt={att.name} className="ai-chat-attachment-img" />
639
+ : <div key={i} className="ai-chat-attachment-file"><AttachmentFileIcon />{att.name}</div>
640
+ ))}
641
+ </div>
642
+ )}
643
+ <UserBubbleContent content={content} />
644
+ </>
463
645
  ) : streamingPrefix !== null ? (
464
646
  <>
465
647
  {streamingPrefix && (
@@ -476,8 +658,31 @@ export default function Message({
476
658
  </div>
477
659
  </div>
478
660
  </>
661
+ ) : stoppedPrefix !== null ? (
662
+ <>
663
+ {stoppedPrefix && (
664
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{stoppedPrefix}</ReactMarkdown>
665
+ )}
666
+ <div className="ai-building-block ai-building-block--stopped" role="status">
667
+ <span className="ai-building-block__label">Rendering stopped</span>
668
+ </div>
669
+ </>
479
670
  ) : (
480
- <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{renderContent}</ReactMarkdown>
671
+ contentSegments.map((seg) =>
672
+ seg.type === 'chart' ? (
673
+ <MemoizedChartBlock key={seg.key} language={seg.language} rawValue={seg.rawValue} />
674
+ ) : (
675
+ <ReactMarkdown key={seg.key} remarkPlugins={[remarkGfm]} components={markdownComponents}>
676
+ {seg.content}
677
+ </ReactMarkdown>
678
+ )
679
+ )
680
+ )}
681
+ {!isUser && stopped && stoppedPrefix === null && (
682
+ <div className="ai-chat-interrupted-notice">Response interrupted</div>
683
+ )}
684
+ {!isUser && showQuerySummary && queryContext && (
685
+ <QuerySummaryPanel queryContext={queryContext} />
481
686
  )}
482
687
  {!isUser && truncated && (
483
688
  <div className="ai-truncation-warning">
@@ -1,11 +1,58 @@
1
1
  import React from "react";
2
2
 
3
+ function csvEscape(value) {
4
+ const s = (value ?? "").toString();
5
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
6
+ }
7
+
8
+ function buildCsv(columns, rows) {
9
+ const header = columns.map((c) => csvEscape(c.label)).join(",");
10
+ const body = rows.map((row) => columns.map((c) => csvEscape(row[c.key])).join(",")).join("\r\n");
11
+ return `${header}\r\n${body}`;
12
+ }
13
+
14
+ function downloadCsv(filename, csv) {
15
+ // Prepend a BOM so Excel reads UTF-8 correctly.
16
+ const blob = new Blob([`${csv}`], { type: "text/csv;charset=utf-8;" });
17
+ const url = URL.createObjectURL(blob);
18
+ const a = document.createElement("a");
19
+ a.href = url;
20
+ a.download = filename;
21
+ document.body.appendChild(a);
22
+ a.click();
23
+ document.body.removeChild(a);
24
+ setTimeout(() => URL.revokeObjectURL(url), 0);
25
+ }
26
+
27
+ function safeFilename(title) {
28
+ const base = (title || "table").replace(/[\\/:*?"<>|]+/g, "").trim().slice(0, 80);
29
+ return `${base || "table"}.csv`;
30
+ }
31
+
3
32
  export default function DataTable({ chart }) {
4
33
  const { title, columns, rows } = chart;
5
34
 
35
+ const handleDownload = () => downloadCsv(safeFilename(title), buildCsv(columns, rows));
36
+
6
37
  return (
7
38
  <div className="ai-chart-card">
8
- {title ? <h4 className="ai-chart-title">{title}</h4> : null}
39
+ <div className="ai-chart-card-header">
40
+ {title ? <h4 className="ai-chart-title">{title}</h4> : <span />}
41
+ <button
42
+ type="button"
43
+ className="ai-chart-csv-btn"
44
+ onClick={handleDownload}
45
+ title="Download as CSV"
46
+ aria-label="Download table as CSV"
47
+ >
48
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
49
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
50
+ <polyline points="7 10 12 15 17 10" />
51
+ <line x1="12" y1="15" x2="12" y2="3" />
52
+ </svg>
53
+ CSV
54
+ </button>
55
+ </div>
9
56
  <div className="ai-data-table-wrap">
10
57
  <table className="ai-data-table">
11
58
  <thead>