orchid-ai 2.0.2 → 2.0.3

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/orchid-ai.css CHANGED
@@ -382,6 +382,323 @@
382
382
  border-bottom-left-radius: 4px;
383
383
  }
384
384
 
385
+ /* ── Tool / preamble trace (Claude-style timeline, light theme) ── */
386
+
387
+ .ai-chat-process-trace {
388
+ --ai-process-bg: #f4f6f8;
389
+ --ai-process-border: #e4e8ed;
390
+ --ai-process-rail: #d8dde4;
391
+ --ai-process-text: #5c6570;
392
+ --ai-process-text-strong: #2d3748;
393
+ --ai-process-dot: #9aa5b1;
394
+ --ai-process-dot-tool: #059669;
395
+ --ai-process-dot-compile: #4f46e5;
396
+ --ai-process-dot-mind: #94a3b8;
397
+ --ai-process-interim-fg: #475569;
398
+
399
+ margin: 0 0 12px;
400
+ border-radius: 12px;
401
+ border: 1px solid transparent;
402
+ background: transparent;
403
+ box-shadow: none;
404
+ font-size: 12.5px;
405
+ line-height: 1.45;
406
+ color: var(--ai-process-text);
407
+ overflow: visible;
408
+ transition:
409
+ background 0.22s ease,
410
+ border-color 0.22s ease,
411
+ box-shadow 0.22s ease;
412
+ }
413
+
414
+ /* Collapsed: no chrome — summary reads as muted underlined text on the bubble background */
415
+ .ai-chat-process-trace:not([open]) {
416
+ overflow: visible;
417
+ }
418
+
419
+ .ai-chat-process-trace[open] {
420
+ overflow: hidden;
421
+ border-color: var(--ai-process-border);
422
+ background: linear-gradient(165deg, var(--ai-process-bg) 0%, #eef1f4 100%);
423
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
424
+ }
425
+
426
+ .ai-chat-process-trace__summary {
427
+ cursor: pointer;
428
+ list-style: none;
429
+ display: flex;
430
+ align-items: center;
431
+ gap: 8px;
432
+ padding: 4px 0 10px;
433
+ margin: 0;
434
+ font-weight: 500;
435
+ font-size: 13px;
436
+ letter-spacing: 0.01em;
437
+ text-transform: none;
438
+ color: #6b7280;
439
+ text-decoration: none;
440
+ user-select: none;
441
+ background: transparent;
442
+ border-bottom: none;
443
+ transition:
444
+ color 0.18s ease,
445
+ padding 0.2s ease,
446
+ font-size 0.2s ease,
447
+ font-weight 0.2s ease,
448
+ letter-spacing 0.2s ease,
449
+ background 0.22s ease,
450
+ border-color 0.22s ease;
451
+ }
452
+
453
+ /* Collapsed: underline only the label — chevron / dots sit beside it, not underlined */
454
+ .ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary {
455
+ gap: 5px;
456
+ width: fit-content;
457
+ max-width: 100%;
458
+ }
459
+
460
+ .ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary:hover {
461
+ color: #4b5563;
462
+ }
463
+
464
+ .ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary:hover .ai-chat-process-trace__summary-text {
465
+ text-decoration-color: #4b5563;
466
+ }
467
+
468
+ .ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary:focus-visible {
469
+ outline: 2px solid #93c5fd;
470
+ outline-offset: 2px;
471
+ border-radius: 4px;
472
+ }
473
+
474
+ /* Expanded: restore panel header strip inside the tinted card */
475
+ .ai-chat-process-trace[open] > .ai-chat-process-trace__summary {
476
+ padding: 10px 14px;
477
+ font-weight: 600;
478
+ font-size: 12px;
479
+ letter-spacing: 0.02em;
480
+ text-transform: uppercase;
481
+ color: var(--ai-process-text-strong);
482
+ text-decoration: none;
483
+ background: rgba(255, 255, 255, 0.45);
484
+ border-bottom: 1px solid var(--ai-process-border);
485
+ }
486
+
487
+ .ai-chat-process-trace__summary::-webkit-details-marker {
488
+ display: none;
489
+ }
490
+
491
+ .ai-chat-process-trace__summary-text {
492
+ flex: 1;
493
+ text-decoration: none;
494
+ }
495
+
496
+ .ai-chat-process-trace:not([open]) .ai-chat-process-trace__summary-text {
497
+ flex: 0 1 auto;
498
+ text-decoration: underline;
499
+ text-underline-offset: 3px;
500
+ text-decoration-thickness: 1px;
501
+ transition: text-decoration-color 0.18s ease;
502
+ }
503
+
504
+ .ai-chat-process-trace[open] .ai-chat-process-trace__summary-text {
505
+ flex: 1;
506
+ text-decoration: none;
507
+ }
508
+
509
+ /* Single expand chevron (right); hidden while streaming — dots replace it. */
510
+ .ai-chat-process-trace__summary-chevron {
511
+ display: inline-flex;
512
+ align-items: center;
513
+ justify-content: center;
514
+ flex-shrink: 0;
515
+ min-width: 1.25em;
516
+ font-size: 15px;
517
+ font-weight: 700;
518
+ line-height: 1;
519
+ opacity: 0.55;
520
+ text-decoration: none;
521
+ transition: transform 0.18s ease, opacity 0.15s ease;
522
+ }
523
+
524
+ .ai-chat-process-trace:not([open]) .ai-chat-process-trace__summary-chevron {
525
+ opacity: 0.45;
526
+ min-width: auto;
527
+ }
528
+
529
+ .ai-chat-process-trace:not([open]) .ai-chat-process-trace__mini-typing span {
530
+ opacity: 0.72;
531
+ }
532
+
533
+ .ai-chat-process-trace[open] > .ai-chat-process-trace__summary .ai-chat-process-trace__summary-chevron {
534
+ transform: rotate(90deg);
535
+ opacity: 0.78;
536
+ }
537
+
538
+ .ai-chat-process-trace__mini-typing {
539
+ display: inline-flex;
540
+ align-items: center;
541
+ flex-shrink: 0;
542
+ gap: 3px;
543
+ }
544
+
545
+ .ai-chat-process-trace__mini-typing span {
546
+ width: 4px;
547
+ height: 4px;
548
+ border-radius: 50%;
549
+ background: var(--ai-process-dot-tool);
550
+ opacity: 0.85;
551
+ animation: ai-chat-process-mini-dot 1.05s ease-in-out infinite;
552
+ }
553
+
554
+ .ai-chat-process-trace__mini-typing span:nth-child(2) {
555
+ animation-delay: 0.12s;
556
+ }
557
+
558
+ .ai-chat-process-trace__mini-typing span:nth-child(3) {
559
+ animation-delay: 0.24s;
560
+ }
561
+
562
+ @keyframes ai-chat-process-mini-dot {
563
+ 0%,
564
+ 80%,
565
+ 100% {
566
+ transform: translateY(0);
567
+ opacity: 0.35;
568
+ }
569
+ 40% {
570
+ transform: translateY(-2px);
571
+ opacity: 1;
572
+ }
573
+ }
574
+
575
+ @keyframes ai-chat-process-pulse {
576
+ 0% {
577
+ box-shadow: 0 0 0 0 rgba(5, 150, 105, 0.35);
578
+ }
579
+ 70% {
580
+ box-shadow: 0 0 0 6px rgba(5, 150, 105, 0);
581
+ }
582
+ 100% {
583
+ box-shadow: 0 0 0 0 rgba(5, 150, 105, 0);
584
+ }
585
+ }
586
+
587
+ .ai-chat-process-trace__panel {
588
+ padding: 10px 12px 12px 8px;
589
+ }
590
+
591
+ .ai-chat-process-trace__timeline {
592
+ list-style: none;
593
+ margin: 0;
594
+ padding: 0;
595
+ }
596
+
597
+ .ai-chat-process-trace__step {
598
+ display: flex;
599
+ align-items: stretch;
600
+ gap: 10px;
601
+ margin: 0;
602
+ padding: 0 0 14px;
603
+ }
604
+
605
+ .ai-chat-process-trace__step:last-child {
606
+ padding-bottom: 0;
607
+ }
608
+
609
+ .ai-chat-process-trace__rail {
610
+ position: relative;
611
+ width: 18px;
612
+ flex-shrink: 0;
613
+ display: flex;
614
+ justify-content: center;
615
+ padding-top: 4px;
616
+ }
617
+
618
+ .ai-chat-process-trace__rail::after {
619
+ content: "";
620
+ position: absolute;
621
+ top: 13px;
622
+ bottom: -14px;
623
+ left: 50%;
624
+ width: 1px;
625
+ transform: translateX(-50%);
626
+ background: var(--ai-process-rail);
627
+ }
628
+
629
+ .ai-chat-process-trace__step:last-child .ai-chat-process-trace__rail::after {
630
+ display: none;
631
+ }
632
+
633
+ .ai-chat-process-trace__dot {
634
+ position: relative;
635
+ z-index: 1;
636
+ width: 7px;
637
+ height: 7px;
638
+ border-radius: 50%;
639
+ background: var(--ai-process-dot);
640
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
641
+ }
642
+
643
+ .ai-chat-process-trace__step--tool .ai-chat-process-trace__dot {
644
+ background: var(--ai-process-dot-tool);
645
+ }
646
+
647
+ .ai-chat-process-trace__step--compile .ai-chat-process-trace__dot {
648
+ background: var(--ai-process-dot-compile);
649
+ }
650
+
651
+ .ai-chat-process-trace__step--mind .ai-chat-process-trace__dot {
652
+ background: var(--ai-process-dot-mind);
653
+ }
654
+
655
+ .ai-chat-process-trace__step--text .ai-chat-process-trace__dot {
656
+ background: var(--ai-process-dot);
657
+ }
658
+
659
+ .ai-chat-process-trace__step--interim .ai-chat-process-trace__dot {
660
+ background: var(--ai-process-dot);
661
+ animation: ai-chat-process-pulse 1.4s ease-out infinite;
662
+ }
663
+
664
+ .ai-chat-process-trace__step--live .ai-chat-process-trace__dot {
665
+ animation: ai-chat-process-pulse 1.2s ease-out infinite;
666
+ }
667
+
668
+ .ai-chat-process-trace__body {
669
+ flex: 1;
670
+ min-width: 0;
671
+ padding-top: 1px;
672
+ }
673
+
674
+ .ai-chat-process-trace__line {
675
+ display: block;
676
+ color: var(--ai-process-text-strong);
677
+ font-weight: 500;
678
+ }
679
+
680
+ .ai-chat-process-trace__step--tool .ai-chat-process-trace__line {
681
+ color: #047857;
682
+ }
683
+
684
+ .ai-chat-process-trace__step--compile .ai-chat-process-trace__line {
685
+ color: #4338ca;
686
+ }
687
+
688
+ .ai-chat-process-trace__prose {
689
+ margin: 0;
690
+ white-space: pre-wrap;
691
+ word-break: break-word;
692
+ color: var(--ai-process-interim-fg);
693
+ font-weight: 400;
694
+ }
695
+
696
+ .ai-chat-process-trace__step--interim .ai-chat-process-trace__prose {
697
+ border-left: 2px solid var(--ai-process-rail);
698
+ padding-left: 10px;
699
+ margin-left: 2px;
700
+ }
701
+
385
702
  /* ── Markdown Typography (assistant bubbles) ── */
386
703
 
387
704
  .ai-chat-bubble.assistant p {
@@ -1816,6 +2133,29 @@
1816
2133
  animation-delay: 0.4s;
1817
2134
  }
1818
2135
 
2136
+ /* Smaller dots for inline status (next to “Compiling…”) */
2137
+ .ai-chat-typing--inline {
2138
+ gap: 4px;
2139
+ padding: 0;
2140
+ }
2141
+
2142
+ .ai-chat-typing--inline span {
2143
+ width: 5px;
2144
+ height: 5px;
2145
+ }
2146
+
2147
+ .ai-chat-streaming-status {
2148
+ display: flex;
2149
+ align-items: center;
2150
+ gap: 8px;
2151
+ flex-wrap: wrap;
2152
+ margin-bottom: 8px;
2153
+ }
2154
+
2155
+ .ai-chat-streaming-status .ai-chat-status-text {
2156
+ margin: 0;
2157
+ }
2158
+
1819
2159
  @keyframes typing {
1820
2160
  0%,
1821
2161
  60%,
@@ -1829,6 +2169,48 @@
1829
2169
  }
1830
2170
  }
1831
2171
 
2172
+ /* ── Building Block (streaming code-block placeholder) ── */
2173
+
2174
+ .ai-building-block {
2175
+ display: flex;
2176
+ align-items: center;
2177
+ gap: 10px;
2178
+ flex-wrap: wrap;
2179
+ padding: 8px 12px;
2180
+ border-radius: 8px;
2181
+ margin: 6px 0 0;
2182
+ background: #f3f4f6;
2183
+ border: 1px solid #e5e7eb;
2184
+ }
2185
+
2186
+ .ai-building-block__label {
2187
+ font-size: 13px;
2188
+ font-weight: 600;
2189
+ color: #4b5563;
2190
+ }
2191
+
2192
+ .ai-building-block__dots {
2193
+ display: flex;
2194
+ align-items: center;
2195
+ gap: 4px;
2196
+ }
2197
+
2198
+ .ai-building-block__dot {
2199
+ width: 5px;
2200
+ height: 5px;
2201
+ border-radius: 50%;
2202
+ background: #9ca3af;
2203
+ animation: typing 1.4s infinite;
2204
+ }
2205
+
2206
+ .ai-building-block__dots .ai-building-block__dot:nth-child(2) {
2207
+ animation-delay: 0.2s;
2208
+ }
2209
+
2210
+ .ai-building-block__dots .ai-building-block__dot:nth-child(3) {
2211
+ animation-delay: 0.4s;
2212
+ }
2213
+
1832
2214
  /* ── Input Area ── */
1833
2215
 
1834
2216
  .ai-chat-input-form {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchid-ai",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Shared Orchid AI chat UI and visualization components",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -22,13 +22,14 @@ export default function ChatWindow({
22
22
  emptyDescription,
23
23
  suggestions = DEFAULT_SUGGESTIONS,
24
24
  suggestionsDisabled = false,
25
+ showProcessTracePanel = true,
25
26
  }) {
26
27
  const exportPrefix = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
27
28
  const bottomRef = useRef(null);
28
29
 
29
30
  useEffect(() => {
30
31
  bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
31
- }, [messages, loading]);
32
+ }, [messages, loading, statusText]);
32
33
 
33
34
  const renderEmptyState = () => {
34
35
  if (!aiEnabled) {
@@ -100,13 +101,32 @@ export default function ChatWindow({
100
101
  );
101
102
  };
102
103
 
104
+ const lastMsg = messages?.[messages.length - 1];
105
+ const hasStreamingMessage = lastMsg?.isStreaming === true;
106
+
103
107
  return (
104
108
  <div className="ai-chat-window">
105
109
  {messages?.length === 0 && !loading && renderEmptyState()}
106
- {(messages ?? []).map((msg, i) => (
107
- <Message key={i} role={msg.role} content={msg.content} truncated={msg.truncated} exportPrefix={exportPrefix} />
108
- ))}
109
- {loading && (
110
+ {(messages ?? []).map((msg, i) => {
111
+ const isLast = i === (messages?.length ?? 0) - 1;
112
+ const streamingStatusText =
113
+ loading && isLast && msg.role === 'assistant' && msg.isStreaming ? statusText : undefined;
114
+ return (
115
+ <Message
116
+ key={i}
117
+ role={msg.role}
118
+ content={msg.content}
119
+ truncated={msg.truncated}
120
+ exportPrefix={exportPrefix}
121
+ isStreaming={msg.isStreaming}
122
+ streamingStatusText={streamingStatusText}
123
+ processTrace={msg.processTrace}
124
+ processInterimLive={msg.processInterimLive}
125
+ showProcessTracePanel={showProcessTracePanel}
126
+ />
127
+ );
128
+ })}
129
+ {loading && !hasStreamingMessage && (
110
130
  <div className="ai-chat-message assistant">
111
131
  <div className="ai-chat-avatar assistant">AI</div>
112
132
  <div className="ai-chat-bubble assistant">
@@ -1,14 +1,66 @@
1
- import React, { useRef, useState, useMemo } from "react";
1
+ import React, { useRef, useState, useMemo, useEffect } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import AiVisualization from "./visualizations/AiVisualization";
5
5
  import { resolveChartBlock } from "./visualizations/parseChartBlock";
6
6
  import { isChartMarkdownLanguage } from "./visualizations/chartSchema";
7
+ import { orchidAiProcessTraceHasDisplayableContent, orchidAiProcessTraceEntryKind } from "../orchidAiProcessTrace";
7
8
 
8
9
  const IS_DEV = process.env.NODE_ENV === "development";
9
10
 
10
- const TITLE_RE = /<!--\s*title:\s*([^-][^>]*?)\s*-->/i;
11
+ /**
12
+ * Returns the substring up to (not including) the last unclosed ``` opener,
13
+ * or null when all code blocks are closed. Used to safely render partial
14
+ * markdown during streaming without passing broken fences to ReactMarkdown.
15
+ */
16
+ function getStreamingPrefix(content) {
17
+ const parts = content.split("```");
18
+ if (parts.length % 2 === 0) {
19
+ return content.slice(0, content.lastIndexOf("```"));
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /** Extracts the language identifier from the currently-open (unclosed) code fence. */
25
+ function getOpenBlockLanguage(content) {
26
+ const lastIdx = content.lastIndexOf("```");
27
+ if (lastIdx === -1) return "";
28
+ const after = content.slice(lastIdx + 3);
29
+ return after.split("\n")[0].trim().toLowerCase();
30
+ }
31
+
32
+ const CODE_LANGS = new Set([
33
+ "javascript","js","typescript","ts","jsx","tsx",
34
+ "python","py","ruby","rb","go","rust","java",
35
+ "c","cpp","c++","cs","php","bash","sh","zsh",
36
+ "html","css","yaml","yml","xml","markdown","md",
37
+ ]);
38
+
39
+ function getBlockLabel(lang) {
40
+ const l = (lang || '').toLowerCase();
41
+ if (l.includes('orchid-ai-chart') || l.includes('hemiq-chart')) return 'Generating visualization';
42
+ if (l === 'json') return 'Generating data';
43
+ if (l === 'sql') return 'Generating query';
44
+ if (CODE_LANGS.has(l)) return 'Generating code';
45
+ if (!l) return 'Generating…';
46
+ return `Generating ${lang}`;
47
+ }
48
+
49
+ const TITLE_COMMENT_RE = /<!--\s*title:\s*([^-][^>]*?)\s*-->/gi;
11
50
 
51
+ /** Prefer the last `<!--title:...-->` so end-of-reply titles work; still removes legacy start-of-reply comments. */
52
+ function peelOrchidAiTitleComment(content, isUser) {
53
+ if (isUser) return { responseTitle: null, renderContent: content };
54
+ const s = String(content ?? "");
55
+ const matches = [...s.matchAll(TITLE_COMMENT_RE)];
56
+ if (matches.length === 0) return { responseTitle: null, renderContent: content };
57
+ const m = matches[matches.length - 1];
58
+ const full = m[0];
59
+ const idx = m.index;
60
+ const responseTitle = m[1].trim();
61
+ const renderContent = (s.slice(0, idx) + s.slice(idx + full.length)).replace(/^\s+/, "").replace(/\s+$/, "");
62
+ return { responseTitle, renderContent };
63
+ }
12
64
  /** Split on http(s) URLs for lightweight linkify in user bubbles (plain text, not full markdown). */
13
65
  const URL_INLINE_RE = /(https?:\/\/[^\s<>`]+)/gi;
14
66
 
@@ -48,21 +100,110 @@ function UserBubbleContent({ content }) {
48
100
  ));
49
101
  }
50
102
 
51
- export default function Message({ role, content, truncated, exportPrefix = "orchid-ai" }) {
103
+ function ProcessTracePanel({ processTrace, processInterimLive, isStreaming, showProcessTracePanel }) {
104
+ if (showProcessTracePanel === false) return null;
105
+
106
+ const autoCollapsed = !isStreaming && processTrace?.defaultCollapsed !== false;
107
+ const [expanded, setExpanded] = useState(() => !autoCollapsed);
108
+
109
+ useEffect(() => {
110
+ setExpanded(!autoCollapsed);
111
+ }, [autoCollapsed]);
112
+
113
+ const hasContent = orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, {
114
+ isStreaming,
115
+ });
116
+ if (!hasContent) return null;
117
+
118
+ const items = processTrace?.items ?? [];
119
+
120
+ return (
121
+ <details
122
+ className="ai-chat-process-trace"
123
+ open={expanded}
124
+ onToggle={(e) => setExpanded(e.target.open)}
125
+ >
126
+ <summary className="ai-chat-process-trace__summary">
127
+ <span className="ai-chat-process-trace__summary-text">Working</span>
128
+ {isStreaming ? (
129
+ <span className="ai-chat-process-trace__mini-typing" aria-hidden title="In progress">
130
+ <span />
131
+ <span />
132
+ <span />
133
+ </span>
134
+ ) : (
135
+ <span className="ai-chat-process-trace__summary-chevron" aria-hidden>
136
+
137
+ </span>
138
+ )}
139
+ </summary>
140
+ <div className="ai-chat-process-trace__panel">
141
+ <ul className="ai-chat-process-trace__timeline">
142
+ {items.map((entry, i) => {
143
+ const lane = orchidAiProcessTraceEntryKind(entry);
144
+ const isLiveStatus = isStreaming && !processInterimLive && i === items.length - 1 && entry.type === "status";
145
+ return (
146
+ <li
147
+ key={i}
148
+ className={`ai-chat-process-trace__step ai-chat-process-trace__step--${lane}${isLiveStatus ? " ai-chat-process-trace__step--live" : ""}`}
149
+ >
150
+ <div className="ai-chat-process-trace__rail" aria-hidden>
151
+ <span className="ai-chat-process-trace__dot" />
152
+ </div>
153
+ <div className="ai-chat-process-trace__body">
154
+ {entry.type === "status" ? (
155
+ <span className="ai-chat-process-trace__line">{entry.value}</span>
156
+ ) : (
157
+ <div className="ai-chat-process-trace__prose">
158
+ {entry.value}
159
+ </div>
160
+ )}
161
+ </div>
162
+ </li>
163
+ );
164
+ })}
165
+ {processInterimLive ? (
166
+ <li className="ai-chat-process-trace__step ai-chat-process-trace__step--text ai-chat-process-trace__step--interim">
167
+ <div className="ai-chat-process-trace__rail" aria-hidden>
168
+ <span className="ai-chat-process-trace__dot" />
169
+ </div>
170
+ <div className="ai-chat-process-trace__body">
171
+ <div className="ai-chat-process-trace__prose">{processInterimLive}</div>
172
+ </div>
173
+ </li>
174
+ ) : null}
175
+ </ul>
176
+ </div>
177
+ </details>
178
+ );
179
+ }
180
+
181
+ export default function Message({
182
+ role,
183
+ content,
184
+ truncated,
185
+ exportPrefix = "orchid-ai",
186
+ isStreaming = false,
187
+ streamingStatusText,
188
+ processTrace,
189
+ processInterimLive = "",
190
+ showProcessTracePanel = true,
191
+ }) {
52
192
  const isUser = role === "user";
53
193
  const [copied, setCopied] = useState(false);
54
194
  const [isPrinting, setIsPrinting] = useState(false);
55
195
  const messageRef = useRef(null);
56
196
 
57
197
  // Extract AI-provided title comment and strip it from rendered content
58
- const { responseTitle, renderContent } = useMemo(() => {
59
- if (isUser) return { responseTitle: null, renderContent: content };
60
- const match = TITLE_RE.exec(content);
61
- return {
62
- responseTitle: match ? match[1].trim() : null,
63
- renderContent: match ? content.replace(match[0], "").replace(/^\s+/, "") : content,
64
- };
65
- }, [content, isUser]);
198
+ const { responseTitle, renderContent } = useMemo(
199
+ () => peelOrchidAiTitleComment(content, isUser),
200
+ [content, isUser]
201
+ );
202
+
203
+ // When streaming, detect an unclosed code block so we can show a labeled placeholder
204
+ // instead of passing a broken fence to ReactMarkdown.
205
+ const streamingPrefix = !isUser && isStreaming ? getStreamingPrefix(renderContent) : null;
206
+ const openBlockLabel = streamingPrefix !== null ? getBlockLabel(getOpenBlockLanguage(renderContent)) : null;
66
207
 
67
208
  const handleCopy = () => {
68
209
  const stripChartBlocks = (value) =>
@@ -86,9 +227,16 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
86
227
  const bubbleContent = messageRef.current.querySelector(".ai-chat-message-content");
87
228
  if (!bubbleContent) return;
88
229
 
230
+ /** Omit interim “Working” tray + streaming status from PDF title fallback. */
231
+ const plainForTitle = (() => {
232
+ const c = bubbleContent.cloneNode(true);
233
+ c.querySelectorAll(".ai-chat-process-trace, .ai-chat-streaming-status").forEach((n) => n.remove());
234
+ return (c.textContent || "").trim();
235
+ })();
236
+
89
237
  // Build filename from AI-provided title or first meaningful line
90
238
  const titleText = responseTitle ||
91
- (bubbleContent.textContent || "").split("\n").map((l) => l.trim()).find(Boolean) ||
239
+ plainForTitle.split("\n").map((l) => l.trim()).find(Boolean) ||
92
240
  "response";
93
241
  const slug = titleText.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "response";
94
242
  const now = new Date();
@@ -98,7 +246,11 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
98
246
  setIsPrinting(true);
99
247
 
100
248
  const clone = bubbleContent.cloneNode(true);
101
- clone.querySelectorAll(".ai-chart-export-actions, .ai-chat-message-actions").forEach((n) => n.remove());
249
+ clone
250
+ .querySelectorAll(
251
+ ".ai-chart-export-actions, .ai-chat-message-actions, .ai-chat-process-trace, .ai-chat-streaming-status"
252
+ )
253
+ .forEach((n) => n.remove());
102
254
 
103
255
  setIsPrinting(true);
104
256
  const previousTitle = document.title;
@@ -133,6 +285,12 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
133
285
  window.print();
134
286
  };
135
287
 
288
+ // Hide the duplicate inline status row when the collapsible Process panel already lists statuses.
289
+ const processPanelVisible =
290
+ !isUser &&
291
+ showProcessTracePanel !== false &&
292
+ orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, { isStreaming });
293
+
136
294
  const markdownComponents = {
137
295
  pre({ children, ...props }) {
138
296
  const onlyChild = React.Children.toArray(children)[0];
@@ -203,8 +361,42 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
203
361
  </div>
204
362
  <div className={`ai-chat-bubble ${role}`}>
205
363
  <div className="ai-chat-message-content">
364
+ {!isUser ? (
365
+ <ProcessTracePanel
366
+ processTrace={processTrace}
367
+ processInterimLive={processInterimLive}
368
+ isStreaming={isStreaming}
369
+ showProcessTracePanel={showProcessTracePanel}
370
+ />
371
+ ) : null}
372
+ {!isUser && isStreaming && streamingStatusText && !processPanelVisible ? (
373
+ <div className="ai-chat-streaming-status" aria-live="polite">
374
+ <span className="ai-chat-status-text">{streamingStatusText}</span>
375
+ <div className="ai-chat-typing ai-chat-typing--inline" aria-hidden>
376
+ <span />
377
+ <span />
378
+ <span />
379
+ </div>
380
+ </div>
381
+ ) : null}
206
382
  {isUser ? (
207
383
  <UserBubbleContent content={content} />
384
+ ) : streamingPrefix !== null ? (
385
+ <>
386
+ {streamingPrefix && (
387
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{streamingPrefix}</ReactMarkdown>
388
+ )}
389
+ <div className="ai-building-block" role="status" aria-label={openBlockLabel || "Building content"}>
390
+ {openBlockLabel ? (
391
+ <span className="ai-building-block__label">{openBlockLabel}</span>
392
+ ) : null}
393
+ <div className="ai-building-block__dots" aria-hidden>
394
+ <span className="ai-building-block__dot" />
395
+ <span className="ai-building-block__dot" />
396
+ <span className="ai-building-block__dot" />
397
+ </div>
398
+ </div>
399
+ </>
208
400
  ) : (
209
401
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{renderContent}</ReactMarkdown>
210
402
  )}
@@ -214,7 +406,7 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
214
406
  </div>
215
407
  )}
216
408
  </div>
217
- {!isUser && (
409
+ {!isUser && !isStreaming && (
218
410
  <div className="ai-chat-message-actions">
219
411
  <button
220
412
  type="button"
@@ -91,15 +91,16 @@ function normalizeYAxis(axis) {
91
91
  return { label, categories };
92
92
  }
93
93
 
94
+ /** When models omit `xAxis.label`, charts still need a string for the axis caption. */
95
+ const DEFAULT_CATEGORY_AXIS_LABEL = "Category";
96
+
94
97
  function normalizeCategoryAxis(axis) {
95
98
  if (!isObject(axis) || !Array.isArray(axis.categories)) {
96
99
  return null;
97
100
  }
98
101
 
99
- const label = typeof axis.label === "string" ? axis.label.trim() : "";
100
- if (!label) {
101
- return null;
102
- }
102
+ const rawLabel = typeof axis.label === "string" ? axis.label.trim() : "";
103
+ const label = rawLabel || DEFAULT_CATEGORY_AXIS_LABEL;
103
104
 
104
105
  const categories = axis.categories
105
106
  .map((category) => {
@@ -171,9 +172,48 @@ function normalizeSeries(series, allowedCategoryKeys) {
171
172
  return normalized.length > 0 ? normalized : null;
172
173
  }
173
174
 
175
+ /**
176
+ * Distinct categorical `y` values from dot_chart payloads (series-based and/or flat `points`).
177
+ */
178
+ function collectDotChartYCategoryKeys(payload) {
179
+ const ordered = [];
180
+ const seen = new Set();
181
+ const addKey = (rawY) => {
182
+ const key = String(rawY ?? "").trim();
183
+ if (!key || seen.has(key)) {
184
+ return;
185
+ }
186
+ seen.add(key);
187
+ ordered.push(key);
188
+ };
189
+
190
+ if (Array.isArray(payload.series)) {
191
+ for (const entry of payload.series) {
192
+ if (!isObject(entry) || !Array.isArray(entry.points)) {
193
+ continue;
194
+ }
195
+ for (const pt of entry.points) {
196
+ if (!isObject(pt)) {
197
+ continue;
198
+ }
199
+ addKey(pt.y);
200
+ }
201
+ }
202
+ }
203
+ if (Array.isArray(payload.points)) {
204
+ for (const pt of payload.points) {
205
+ if (!isObject(pt)) {
206
+ continue;
207
+ }
208
+ addKey(pt.y);
209
+ }
210
+ }
211
+ return ordered;
212
+ }
213
+
174
214
  /**
175
215
  * Dot charts: models often send yAxis.label only and put category keys in point.y.
176
- * Build categories from series points when yAxis.categories is missing or invalid.
216
+ * Infer categories from `series[].points` and/or top-level `points`.
177
217
  */
178
218
  function mergeDotChartYAxisWithInferredCategories(payload) {
179
219
  if (!isObject(payload) || payload.type !== DOT_CHART_TYPE) {
@@ -188,37 +228,30 @@ function mergeDotChartYAxisWithInferredCategories(payload) {
188
228
  }
189
229
 
190
230
  const yLabel = typeof yRaw.label === "string" ? yRaw.label.trim() : "";
191
- if (!yLabel || !Array.isArray(payload.series)) {
231
+ if (!yLabel) {
192
232
  return yRaw;
193
233
  }
194
234
 
195
- const seen = new Map();
196
- for (const entry of payload.series) {
197
- if (!isObject(entry) || !Array.isArray(entry.points)) {
198
- continue;
199
- }
200
- for (const pt of entry.points) {
201
- if (!isObject(pt)) {
202
- continue;
203
- }
204
- const k = String(pt.y || "").trim();
205
- if (!k || seen.has(k)) {
206
- continue;
207
- }
208
- const disp =
209
- typeof pt.label === "string" && pt.label.trim() ? String(pt.label).trim() : k;
210
- seen.set(k, disp);
211
- }
212
- }
213
-
214
- if (seen.size === 0) {
235
+ const keys = collectDotChartYCategoryKeys(payload);
236
+ if (keys.length === 0) {
215
237
  return yRaw;
216
238
  }
217
239
 
218
- const categories = [...seen.entries()].map(([key, label]) => ({ key: key, label: label }));
240
+ const categories = keys.map((key) => ({ key: key, label: key }));
219
241
  return { ...yRaw, categories: categories };
220
242
  }
221
243
 
244
+ /** Prefer `series`; otherwise wrap flat `points` for {@link normalizeSeries}. */
245
+ function getDotChartSeriesInput(payload) {
246
+ if (Array.isArray(payload.series) && payload.series.length > 0) {
247
+ return payload.series;
248
+ }
249
+ if (Array.isArray(payload.points) && payload.points.length > 0) {
250
+ return [{ name: "Points", points: payload.points }];
251
+ }
252
+ return null;
253
+ }
254
+
222
255
  function normalizeValueSeries(series, allowedCategoryKeys, options = {}) {
223
256
  const { allowNegativeValues = true } = options;
224
257
 
@@ -560,6 +593,23 @@ function tryCoerceHistogramBinsFromLLM(bins) {
560
593
  continue;
561
594
  }
562
595
 
596
+ /** e.g. `"8"` or `"12"` — treat as unit-width bucket [n, n + 1) for numeric axes */
597
+ const loneNumMatch = /^(\d+(?:\.\d+)?)\s*$/i.exec(rangeStr);
598
+ if (loneNumMatch) {
599
+ const a = Number(loneNumMatch[1]);
600
+ if (!Number.isFinite(a)) {
601
+ return null;
602
+ }
603
+ work.push({
604
+ start: a,
605
+ end: a + 1,
606
+ value: value,
607
+ label: rangeStr,
608
+ open: false,
609
+ });
610
+ continue;
611
+ }
612
+
563
613
  return null;
564
614
  }
565
615
 
@@ -1137,8 +1187,9 @@ export function validateDotChartPayload(payload) {
1137
1187
  return { valid: false, error: "Invalid yAxis. Expected label and at least one category with key/label." };
1138
1188
  }
1139
1189
 
1190
+ const seriesInput = getDotChartSeriesInput(payload);
1140
1191
  const series = normalizeSeries(
1141
- payload.series,
1192
+ seriesInput,
1142
1193
  yAxis.categories.map((category) => category.key)
1143
1194
  );
1144
1195
  if (!series) {
@@ -14,9 +14,9 @@ table | bar_chart | line_chart | stacked_bar_chart | grouped_bar_chart | dot_cha
14
14
 
15
15
  Do not invent other type names. For several metrics at a glance use **stat_cards** (\`cards\`: [{ \`label\` or \`title\`, \`value\`, optional \`subtitle\`, \`unit\`, \`trend\`: up|down|neutral }]); for tabular listings use **table**; category counts use **bar_chart** (\`bars\`: [{ "label", "value" }], values ≥ 0). **table**: either \`columns\`: [{ "key", "label" }] with \`rows\`: objects keyed by those keys, OR \`columns\` as a string array (headers) with each \`row\` a string array of values in column order.
16
16
 
17
- **line_chart** / **stacked_bar_chart** / **grouped_bar_chart**: \`xAxis.categories\` (≥2 entries with key+label), \`yAxis\` with label, \`series[].points\` with x = category key and numeric value.
17
+ **line_chart** / **stacked_bar_chart** / **grouped_bar_chart**: \`xAxis\` must include **\`label\`** (human-readable axis title, e.g. \`"Date"\`, \`"Week"\`) **and** \`categories\` (≥2 objects, each with \`key\` + \`label\`). \`yAxis\` must include \`label\`. \`series[].points\`: \`x\` = each category’s \`key\`, \`value\` = number (stacked/grouped: values ≥ 0).
18
18
 
19
- **timeline**: \`items\` or \`events\` with label, start, end (ISO 8601). **dot_chart**: numeric \`x\`, categorical \`y\` per point; \`yAxis.categories\` may be omitted (inferred from point \`y\` / \`label\`). **histogram**: bins as \`start\`/\`end\`/\`value\`, or \`range\` (\`"0-1"\`, \`"6+"\`) with \`count\` or \`value\`. **scatter_plot**: standard numeric axes/series.
19
+ **timeline**: \`items\` or \`events\` with label, start, end (ISO 8601). **dot_chart**: numeric \`x\`, categorical \`y\` (row/category key) per point; optional per-point \`label\` for tooltips only. Use either \`series\`: [{ \`name\`, \`points\` }] **or** a top-level \`points\` array; \`yAxis\` needs \`label\`; \`yAxis.categories\` may be omitted (inferred from distinct \`y\`). **histogram**: bins prefer \`start\`, \`end\`, \`value\` (non-negative, end > start); or \`range\` + \`count\`/\`value\` where \`range\` is \`"min-max"\`, \`"8"\` (single numeric bucket), or \`"6+"\` (open upper). **scatter_plot**: standard numeric axes/series.
20
20
 
21
21
  In prose-only replies (capabilities, no data): do not include an orchid-ai-chart block. When rendering data, skip long schema tutorials—output valid JSON only in the fence.
22
22
  ~10 table rows unless the user asks for more.
@@ -1,15 +1,25 @@
1
1
  import { useState, useCallback, useRef } from 'react';
2
+ import {
3
+ ORCHID_AI_SSE_STATUS_CLEAR_STREAM,
4
+ orchidAiStatusClearsStreamBuffer,
5
+ } from '../orchidAiStreamingTitle';
6
+ import {
7
+ augmentLiveProcessTraceSnapshot,
8
+ createOrchidAiProcessTraceCollector,
9
+ snapshotOrchidAiProcessTraceItems,
10
+ orchidAiProcessTraceHasDisplayableContent,
11
+ } from '../orchidAiProcessTrace';
2
12
 
3
13
  /**
4
14
  * Default status message strings. Export these so server-side code can import
5
15
  * and reference the same values, making it easy to keep client and server in sync
6
16
  * or swap them out per-app.
7
17
  *
8
- * Set showStatus: false on the hook to disable status display entirely.
18
+ * Set showStatus: false on the hook to suppress status display entirely.
9
19
  */
10
20
  export const ORCHID_AI_DEFAULT_STATUS = {
11
21
  thinking: 'Thinking',
12
- compilingResponse: 'Compiling response',
22
+ compilingResponse: ORCHID_AI_SSE_STATUS_CLEAR_STREAM,
13
23
  lookingUpData: 'Looking up data',
14
24
  };
15
25
 
@@ -32,12 +42,8 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
32
42
  const [loading, setLoading] = useState(false);
33
43
  const [statusText, setStatusText] = useState('');
34
44
 
35
- // Track messages in a ref so sendMessage always reads the latest without needing
36
- // messages in its dependency array (avoids capturing stale history).
37
45
  const messagesRef = useRef(initialMessages);
38
46
 
39
- // Keep latest callbacks in refs so sendMessage identity stays stable regardless
40
- // of whether the parent re-creates buildBody/getHeaders each render.
41
47
  const buildBodyRef = useRef(buildBody);
42
48
  const getHeadersRef = useRef(getHeaders);
43
49
  buildBodyRef.current = buildBody;
@@ -70,10 +76,39 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
70
76
 
71
77
  const contentType = response.headers.get('content-type') ?? '';
72
78
 
73
- if (contentType.includes('text/event-stream')) {
79
+ if (contentType.includes('text/event-stream') && response.body) {
74
80
  const reader = response.body.getReader();
75
81
  const decoder = new TextDecoder();
76
82
  let buffer = '';
83
+ const collector = createOrchidAiProcessTraceCollector();
84
+
85
+ addMessage({
86
+ role: 'assistant',
87
+ content: '',
88
+ isStreaming: true,
89
+ processTrace: snapshotOrchidAiProcessTraceItems(collector),
90
+ processInterimLive: '',
91
+ });
92
+
93
+ const patchStreamingAssistant = () => {
94
+ setMessages((prev) => {
95
+ const next = [...prev];
96
+ const last = next[next.length - 1];
97
+ if (last?.role !== 'assistant' || last?.isStreaming !== true) return prev;
98
+ next[next.length - 1] = {
99
+ ...last,
100
+ content: collector.getLiveMain(),
101
+ processTrace: augmentLiveProcessTraceSnapshot(
102
+ snapshotOrchidAiProcessTraceItems(collector),
103
+ collector.getLiveMain(),
104
+ collector.getLiveInterim()
105
+ ),
106
+ processInterimLive: collector.getLiveInterim(),
107
+ };
108
+ messagesRef.current = next;
109
+ return next;
110
+ });
111
+ };
77
112
 
78
113
  while (true) {
79
114
  const { done, value } = await reader.read();
@@ -86,18 +121,50 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
86
121
  if (!line.startsWith('data: ')) continue;
87
122
  try {
88
123
  const event = JSON.parse(line.slice(6));
89
- if (event.type === 'status' && showStatus) {
90
- setStatusText(event.text);
124
+ if (event.type === 'status') {
125
+ if (showStatus) setStatusText(event.text);
126
+ collector.onStatus(event.text, {
127
+ isClearStream: orchidAiStatusClearsStreamBuffer(event.text),
128
+ });
129
+ patchStreamingAssistant();
130
+ } else if (event.type === 'delta') {
131
+ collector.onDelta(event.text);
132
+ patchStreamingAssistant();
91
133
  } else if (event.type === 'done') {
92
- addMessage({
93
- role: 'assistant',
94
- content: event.response,
95
- truncated: event.truncated,
134
+ const rawTrace = collector.buildPersistedTrace();
135
+ const trace =
136
+ rawTrace && orchidAiProcessTraceHasDisplayableContent(rawTrace, '')
137
+ ? rawTrace
138
+ : undefined;
139
+ setMessages((prev) => {
140
+ const next = [...prev];
141
+ const last = next[next.length - 1];
142
+ const finalMsg = {
143
+ role: 'assistant',
144
+ content: event.response,
145
+ truncated: event.truncated === true,
146
+ ...(trace ? { processTrace: trace } : {}),
147
+ };
148
+ if (last?.role === 'assistant' && last?.isStreaming) {
149
+ next[next.length - 1] = finalMsg;
150
+ } else {
151
+ next.push(finalMsg);
152
+ }
153
+ messagesRef.current = next;
154
+ return next;
96
155
  });
97
156
  } else if (event.type === 'error') {
98
- addMessage({
99
- role: 'assistant',
100
- content: event.error || 'Something went wrong.',
157
+ setMessages((prev) => {
158
+ const next = [...prev];
159
+ const last = next[next.length - 1];
160
+ const errMsg = { role: 'assistant', content: event.error || 'Something went wrong.' };
161
+ if (last?.role === 'assistant' && last?.isStreaming) {
162
+ next[next.length - 1] = errMsg;
163
+ } else {
164
+ next.push(errMsg);
165
+ }
166
+ messagesRef.current = next;
167
+ return next;
101
168
  });
102
169
  }
103
170
  } catch {
@@ -106,7 +173,6 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
106
173
  }
107
174
  }
108
175
  } else {
109
- // Plain JSON fallback for non-streaming backends
110
176
  const data = await response.json();
111
177
  if (response.ok) {
112
178
  addMessage({ role: 'assistant', content: data.response, truncated: data.truncated });
package/src/index.d.ts CHANGED
@@ -6,6 +6,15 @@ export interface ChatMessage {
6
6
  role: 'user' | 'assistant';
7
7
  content: string;
8
8
  truncated?: boolean;
9
+ /** When true, UI may show streaming placeholders (orchid-ai ChatWindow). */
10
+ isStreaming?: boolean;
11
+ /** Collapsible interim trace (statuses + pre-final text). Persisted for Hermes-style chats. */
12
+ processTrace?: {
13
+ items: Array<{ type: 'status' | 'text'; value: string }>;
14
+ defaultCollapsed?: boolean;
15
+ };
16
+ /** Live tool preamble not yet flushed into processTrace.items (streaming only). */
17
+ processInterimLive?: string;
9
18
  }
10
19
 
11
20
  export interface ChatWindowProps {
@@ -15,6 +24,8 @@ export interface ChatWindowProps {
15
24
  onSuggestionClick?: (text: string) => void;
16
25
  aiEnabled?: boolean;
17
26
  organisationName?: string;
27
+ /** When false, tool/interim statuses show inline next to typing dots (Hermes / iLink config). */
28
+ showProcessTracePanel?: boolean;
18
29
  /** Any extra props are forwarded to the root element */
19
30
  [key: string]: unknown;
20
31
  }
@@ -31,6 +42,13 @@ export interface MessageProps {
31
42
  role: 'user' | 'assistant';
32
43
  content: string;
33
44
  truncated?: boolean;
45
+ exportPrefix?: string;
46
+ isStreaming?: boolean;
47
+ /** Shown above streaming content while server emits status (e.g. “Compiling response”). */
48
+ streamingStatusText?: string;
49
+ processTrace?: ChatMessage['processTrace'];
50
+ processInterimLive?: string;
51
+ showProcessTracePanel?: boolean;
34
52
  }
35
53
 
36
54
  export const ChatWindow: React.FC<ChatWindowProps>;
@@ -134,6 +152,45 @@ export function validateTablePayload(data: unknown): boolean;
134
152
  export function parseChartBlock(block: string): unknown;
135
153
  export function resolveChartBlock(block: string): unknown;
136
154
 
155
+ // ─── Process trace + SSE markers (Hermes) ───────────────────────────────────
156
+
157
+ export function orchidAiProcessTraceEntryKind(entry: {
158
+ type: 'status' | 'text';
159
+ value: string;
160
+ }): 'text' | 'tool' | 'compile' | 'mind';
161
+
162
+ export function createOrchidAiProcessTraceCollector(): {
163
+ onStatus(text: string, opts?: { isClearStream?: boolean }): void;
164
+ onDelta(text: string): void;
165
+ getLiveMain(): string;
166
+ getLiveInterim(): string;
167
+ getItems(): Array<{ type: 'status' | 'text'; value: string }>;
168
+ reset(): void;
169
+ buildPersistedTrace():
170
+ | { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean }
171
+ | undefined;
172
+ };
173
+
174
+ export function snapshotOrchidAiProcessTraceItems(
175
+ collector: ReturnType<typeof createOrchidAiProcessTraceCollector>
176
+ ): { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean };
177
+
178
+ export function augmentLiveProcessTraceSnapshot(
179
+ trace: { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean },
180
+ streamingMain: string,
181
+ liveInterim?: string
182
+ ): { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean };
183
+
184
+ export function orchidAiProcessTraceHasDisplayableContent(
185
+ trace: ChatMessage['processTrace'] | undefined,
186
+ liveInterim: string | undefined,
187
+ options?: { isStreaming?: boolean }
188
+ ): boolean;
189
+
190
+ /** SSE status before post-tool assistant text; client separates interim vs final answer. */
191
+ export const ORCHID_AI_SSE_STATUS_CLEAR_STREAM: string;
192
+ export function orchidAiStatusClearsStreamBuffer(statusText: unknown): boolean;
193
+
137
194
  // ─── Constants ───────────────────────────────────────────────────────────────
138
195
 
139
196
  export const ORCHID_AI_VISUALIZATION_INSTRUCTIONS: string;
package/src/index.js CHANGED
@@ -50,3 +50,14 @@ export {
50
50
 
51
51
  // AI system prompt constant
52
52
  export { ORCHID_AI_VISUALIZATION_INSTRUCTIONS } from './constants/visualizationInstructions';
53
+
54
+ export {
55
+ augmentLiveProcessTraceSnapshot,
56
+ createOrchidAiProcessTraceCollector,
57
+ orchidAiProcessTraceHasDisplayableContent,
58
+ snapshotOrchidAiProcessTraceItems,
59
+ orchidAiProcessTraceEntryKind,
60
+ } from './orchidAiProcessTrace';
61
+
62
+ // SSE + interim/process trace (Hermes)
63
+ export { ORCHID_AI_SSE_STATUS_CLEAR_STREAM, orchidAiStatusClearsStreamBuffer } from './orchidAiStreamingTitle';
@@ -0,0 +1,163 @@
1
+ import { ORCHID_AI_SSE_STATUS_CLEAR_STREAM } from './orchidAiStreamingTitle';
2
+
3
+ /**
4
+ * Visual timeline lane for Message.jsx: tool-style statuses vs compile vs generic vs prose.
5
+ * @param {{ type: 'status' | 'text', value: string }} entry
6
+ * @returns {'text' | 'tool' | 'compile' | 'mind'}
7
+ */
8
+ export function orchidAiProcessTraceEntryKind(entry) {
9
+ if (!entry || entry.type === 'text') return 'text';
10
+ const t = String(entry.value || '');
11
+ if (t.trim() === ORCHID_AI_SSE_STATUS_CLEAR_STREAM) return 'compile';
12
+ if (
13
+ /^Looking up/i.test(t) ||
14
+ /^Found \d+/i.test(t) ||
15
+ /^Searching the web/i.test(t) ||
16
+ /^Searching knowledge base/i.test(t)
17
+ ) {
18
+ return 'tool';
19
+ }
20
+ return 'mind';
21
+ }
22
+
23
+ /**
24
+ * Collects interim tool/preamble text + statuses for the Hermes / Orchid SSE stream.
25
+ * Final answer deltas go to getLiveMain(); pre-tool/streaming preamble goes to
26
+ * getLiveInterim() and (on "Compiling response") into frozen `items`.
27
+ *
28
+ * Phase machine:
29
+ * - `open_answer`: deltas accumulate in main (direct replies, or post-compile answer).
30
+ * - When a tool-style status arrives (e.g. "Looking up jobs"), migrate main → interim and enter `tool_interim`.
31
+ * - `tool_interim`: deltas go to interim; on `Compiling response`, flush interim to items and return to `open_answer`.
32
+ */
33
+ export function createOrchidAiProcessTraceCollector() {
34
+ /** @type {'open_answer' | 'tool_interim'} */
35
+ let phase = 'open_answer';
36
+ let preBuf = '';
37
+ let mainBuf = '';
38
+ /** @type {{ type: 'status' | 'text', value: string }[]} */
39
+ const items = [];
40
+
41
+ /** @param {string} text */
42
+ function toolishStatus(text) {
43
+ const t = String(text || '');
44
+ return (
45
+ /^Looking up/i.test(t) ||
46
+ /^Found \d+/i.test(t) ||
47
+ /^Searching the web/i.test(t) ||
48
+ /^Searching knowledge base/i.test(t)
49
+ );
50
+ }
51
+
52
+ return {
53
+ /**
54
+ * @param {string} text
55
+ * @param {{ isClearStream?: boolean }} [opts] — true for {@link ORCHID_AI_SSE_STATUS_CLEAR_STREAM}
56
+ */
57
+ onStatus(text, opts = {}) {
58
+ const isClearStream = opts.isClearStream === true;
59
+ if (isClearStream) {
60
+ if (preBuf) items.push({ type: 'text', value: preBuf });
61
+ preBuf = '';
62
+ phase = 'open_answer';
63
+ items.push({ type: 'status', value: String(text) });
64
+ return;
65
+ }
66
+ if (toolishStatus(text) && phase === 'open_answer') {
67
+ phase = 'tool_interim';
68
+ if (mainBuf) {
69
+ preBuf = mainBuf;
70
+ mainBuf = '';
71
+ }
72
+ }
73
+ items.push({ type: 'status', value: String(text) });
74
+ },
75
+ /** @param {string} text */
76
+ onDelta(text) {
77
+ const t = String(text);
78
+ if (phase === 'tool_interim') preBuf += t;
79
+ else mainBuf += t;
80
+ },
81
+ getLiveMain() {
82
+ return mainBuf;
83
+ },
84
+ getLiveInterim() {
85
+ return phase === 'tool_interim' ? preBuf : '';
86
+ },
87
+ /** @returns {{ type: 'status' | 'text', value: string }[]} */
88
+ getItems() {
89
+ return items;
90
+ },
91
+ reset() {
92
+ phase = 'open_answer';
93
+ preBuf = '';
94
+ mainBuf = '';
95
+ items.length = 0;
96
+ },
97
+ /** Persisted shape for assistant bubbles; omit when nothing to show. */
98
+ buildPersistedTrace() {
99
+ if (!items.length) return undefined;
100
+ return {
101
+ items: items.map((x) => ({ type: x.type, value: x.value })),
102
+ defaultCollapsed: true,
103
+ };
104
+ },
105
+ };
106
+ }
107
+
108
+ /** Snapshot `items` for React state / persisted shape (live streaming; not collapsed). */
109
+ export function snapshotOrchidAiProcessTraceItems(collector) {
110
+ const items = collector.getItems();
111
+ return {
112
+ items: items.map((x) => ({ type: x.type, value: x.value })),
113
+ defaultCollapsed: false,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Display-only: Hermes sends one `status` ("Thinking") then `delta` chunks for direct replies.
119
+ * Deltas never become timeline rows, so the Working panel would stay on Thinking forever.
120
+ * Append a synthetic step while streamed content exists (not persisted — see `buildPersistedTrace`).
121
+ *
122
+ * @param {{ items: Array<{ type: 'status' | 'text', value: string }>, defaultCollapsed: boolean }} trace
123
+ * @param {string} streamingMain
124
+ * @param {string} [liveInterim]
125
+ */
126
+ export function augmentLiveProcessTraceSnapshot(trace, streamingMain, liveInterim = '') {
127
+ if (!trace || !Array.isArray(trace.items)) return trace;
128
+ const items = trace.items;
129
+ const mainTrim = typeof streamingMain === 'string' ? streamingMain.trim() : '';
130
+ const interimTrim = typeof liveInterim === 'string' ? liveInterim.trim() : '';
131
+
132
+ const onlyThinking =
133
+ items.length === 1 &&
134
+ items[0]?.type === 'status' &&
135
+ String(items[0].value || '').trim().toLowerCase() === 'thinking';
136
+
137
+ if (!onlyThinking) return trace;
138
+
139
+ if (mainTrim.length > 0) {
140
+ return {
141
+ ...trace,
142
+ items: [...items, { type: 'status', value: 'Drafting response' }],
143
+ };
144
+ }
145
+ if (interimTrim.length > 0) {
146
+ return {
147
+ ...trace,
148
+ items: [...items, { type: 'status', value: 'Gathering details' }],
149
+ };
150
+ }
151
+ return trace;
152
+ }
153
+
154
+ /** Whether to render the process trace panel. Pass `{ isStreaming: true }` during SSE so a lone “Thinking” status still opens the live tray. */
155
+ export function orchidAiProcessTraceHasDisplayableContent(trace, liveInterim, options) {
156
+ const isStreaming = options?.isStreaming === true;
157
+ if (typeof liveInterim === 'string' && liveInterim.trim() !== '') return true;
158
+ const list = trace?.items;
159
+ if (!Array.isArray(list) || list.length === 0) return false;
160
+ if (isStreaming) return true;
161
+ if (list.some((i) => i && i.type === 'text' && String(i.value || '').trim() !== '')) return true;
162
+ return list.length > 1;
163
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * SSE `status` emitted by Hermes immediately before streaming the post-tool assistant message.
3
+ * Clients use this to separate interim text from the final answer (see `orchidAiProcessTrace`).
4
+ *
5
+ * Keep in sync with `onStatus?.('Compiling response')` in iLink `orchidAiAnthropicChat.js`.
6
+ */
7
+ export const ORCHID_AI_SSE_STATUS_CLEAR_STREAM = 'Compiling response';
8
+
9
+ /** @param {unknown} statusText from `{ type: 'status', text }` */
10
+ export function orchidAiStatusClearsStreamBuffer(statusText) {
11
+ return typeof statusText === 'string' && statusText.trim() === ORCHID_AI_SSE_STATUS_CLEAR_STREAM;
12
+ }