orchid-ai 2.1.4 → 2.1.5

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
@@ -374,6 +374,39 @@
374
374
  color: #ffffff;
375
375
  }
376
376
 
377
+ /* ── Attachment display inside user bubbles ── */
378
+
379
+ .ai-chat-user-attachments {
380
+ display: flex;
381
+ flex-wrap: wrap;
382
+ gap: 8px;
383
+ margin-bottom: 8px;
384
+ }
385
+
386
+ .ai-chat-attachment-img {
387
+ max-width: 220px;
388
+ max-height: 180px;
389
+ border-radius: 8px;
390
+ object-fit: cover;
391
+ display: block;
392
+ }
393
+
394
+ .ai-chat-attachment-file {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 6px;
398
+ padding: 6px 10px;
399
+ background: rgba(255, 255, 255, 0.18);
400
+ border: 1px solid rgba(255, 255, 255, 0.25);
401
+ border-radius: 8px;
402
+ font-size: 12px;
403
+ color: rgba(255, 255, 255, 0.9);
404
+ max-width: 200px;
405
+ overflow: hidden;
406
+ text-overflow: ellipsis;
407
+ white-space: nowrap;
408
+ }
409
+
377
410
  .ai-chat-bubble.assistant {
378
411
  position: relative;
379
412
  background: #ffffff;
@@ -2183,6 +2216,23 @@
2183
2216
  word-break: break-word;
2184
2217
  }
2185
2218
 
2219
+ .ai-chat-process-url {
2220
+ color: var(--ai-accent, #3b82f6);
2221
+ text-decoration: none;
2222
+ }
2223
+ .ai-chat-process-url:hover {
2224
+ text-decoration: underline;
2225
+ }
2226
+
2227
+ .ai-chat-process-url-list {
2228
+ list-style: none;
2229
+ margin: 0;
2230
+ padding: 0;
2231
+ display: flex;
2232
+ flex-direction: column;
2233
+ gap: 2px;
2234
+ }
2235
+
2186
2236
  /* ── Message Actions ── */
2187
2237
 
2188
2238
  .ai-chat-message-actions {
@@ -2279,6 +2329,74 @@
2279
2329
  height: 5px;
2280
2330
  }
2281
2331
 
2332
+ /* Queued (pending) messages — shown as muted user bubbles while a prior request runs */
2333
+
2334
+ .ai-chat-message--queued {
2335
+ animation: none;
2336
+ }
2337
+
2338
+ .ai-chat-bubble--queued {
2339
+ opacity: 0.72;
2340
+ display: flex;
2341
+ flex-direction: column;
2342
+ gap: 8px;
2343
+ }
2344
+
2345
+ .ai-chat-pending-msg-text {
2346
+ white-space: pre-wrap;
2347
+ word-break: break-word;
2348
+ }
2349
+
2350
+ .ai-chat-pending-footer {
2351
+ display: flex;
2352
+ align-items: center;
2353
+ gap: 8px;
2354
+ flex-wrap: wrap;
2355
+ }
2356
+
2357
+ .ai-chat-pending-status {
2358
+ font-size: 11px;
2359
+ color: rgba(255, 255, 255, 0.6);
2360
+ font-style: italic;
2361
+ flex: 1;
2362
+ min-width: 0;
2363
+ }
2364
+
2365
+ .ai-chat-pending-actions {
2366
+ display: flex;
2367
+ gap: 6px;
2368
+ flex-wrap: wrap;
2369
+ }
2370
+
2371
+ .ai-chat-pending-btn {
2372
+ font-size: 11px;
2373
+ font-weight: 500;
2374
+ padding: 3px 10px;
2375
+ border-radius: 5px;
2376
+ border: 1px solid rgba(255, 255, 255, 0.3);
2377
+ background: rgba(255, 255, 255, 0.15);
2378
+ color: rgba(255, 255, 255, 0.9);
2379
+ cursor: pointer;
2380
+ transition: background 0.15s, border-color 0.15s;
2381
+ }
2382
+
2383
+ .ai-chat-pending-btn:hover {
2384
+ background: rgba(255, 255, 255, 0.25);
2385
+ border-color: rgba(255, 255, 255, 0.5);
2386
+ }
2387
+
2388
+ .ai-chat-pending-btn--primary {
2389
+ background: rgba(255, 255, 255, 0.28);
2390
+ border-color: rgba(255, 255, 255, 0.55);
2391
+ color: #ffffff;
2392
+ font-weight: 600;
2393
+ }
2394
+
2395
+ .ai-chat-pending-btn--primary:hover {
2396
+ background: rgba(255, 255, 255, 0.42);
2397
+ border-color: rgba(255, 255, 255, 0.75);
2398
+ }
2399
+
2282
2400
  .ai-chat-streaming-status {
2283
2401
  display: flex;
2284
2402
  align-items: center;
@@ -2318,6 +2436,19 @@
2318
2436
  border: 1px solid #e5e7eb;
2319
2437
  }
2320
2438
 
2439
+ .ai-building-block--stopped {
2440
+ background: #fafafa;
2441
+ border-color: #e5e7eb;
2442
+ opacity: 0.65;
2443
+ }
2444
+
2445
+ .ai-chat-interrupted-notice {
2446
+ font-size: 12px;
2447
+ color: #9ca3af;
2448
+ font-style: italic;
2449
+ margin-top: 8px;
2450
+ }
2451
+
2321
2452
  .ai-building-block__label {
2322
2453
  font-size: 13px;
2323
2454
  font-weight: 600;
@@ -2357,18 +2488,123 @@
2357
2488
 
2358
2489
  .ai-chat-input-wrapper {
2359
2490
  display: flex;
2360
- align-items: center;
2491
+ flex-direction: column;
2361
2492
  position: relative;
2362
- gap: 10px;
2363
2493
  max-width: 768px;
2364
2494
  margin: 0 auto;
2365
2495
  background: #f9fafb;
2366
2496
  border: 1px solid #d1d5db;
2367
2497
  border-radius: 14px;
2368
- padding: 8px 8px 8px 16px;
2498
+ overflow: hidden;
2369
2499
  transition: border-color 0.2s, box-shadow 0.2s;
2370
2500
  }
2371
2501
 
2502
+ /* ── Attachment preview strip ── */
2503
+
2504
+ .ai-attach-strip {
2505
+ display: flex;
2506
+ flex-wrap: wrap;
2507
+ gap: 6px;
2508
+ padding: 10px 12px 8px;
2509
+ border-bottom: 1px solid #e5e7eb;
2510
+ }
2511
+
2512
+ .ai-attach-chip {
2513
+ display: flex;
2514
+ align-items: center;
2515
+ gap: 5px;
2516
+ padding: 4px 6px 4px 8px;
2517
+ background: #e5e7eb;
2518
+ border-radius: 8px;
2519
+ font-size: 12px;
2520
+ color: #374151;
2521
+ max-width: 180px;
2522
+ }
2523
+
2524
+ .ai-attach-chip--img {
2525
+ padding: 3px 6px 3px 3px;
2526
+ }
2527
+
2528
+ .ai-attach-thumb {
2529
+ width: 42px;
2530
+ height: 42px;
2531
+ object-fit: cover;
2532
+ border-radius: 6px;
2533
+ flex-shrink: 0;
2534
+ display: block;
2535
+ }
2536
+
2537
+ .ai-attach-chip-icon {
2538
+ display: flex;
2539
+ align-items: center;
2540
+ color: #6b7280;
2541
+ flex-shrink: 0;
2542
+ }
2543
+
2544
+ .ai-attach-chip-name {
2545
+ flex: 1;
2546
+ overflow: hidden;
2547
+ text-overflow: ellipsis;
2548
+ white-space: nowrap;
2549
+ min-width: 0;
2550
+ }
2551
+
2552
+ .ai-attach-chip-remove {
2553
+ border: none;
2554
+ background: none;
2555
+ color: #9ca3af;
2556
+ cursor: pointer;
2557
+ padding: 0 1px;
2558
+ font-size: 16px;
2559
+ line-height: 1;
2560
+ flex-shrink: 0;
2561
+ display: flex;
2562
+ align-items: center;
2563
+ }
2564
+
2565
+ .ai-attach-chip-remove:hover {
2566
+ color: #ef4444;
2567
+ }
2568
+
2569
+ /* ── Input row (attach btn + textarea + send btn) ── */
2570
+
2571
+ .ai-chat-input-row {
2572
+ display: flex;
2573
+ align-items: center;
2574
+ gap: 8px;
2575
+ padding: 8px 8px 8px 10px;
2576
+ }
2577
+
2578
+ .ai-attach-file-input {
2579
+ display: none;
2580
+ }
2581
+
2582
+ .ai-attach-btn {
2583
+ display: flex;
2584
+ align-items: center;
2585
+ justify-content: center;
2586
+ width: 32px;
2587
+ height: 32px;
2588
+ border: none;
2589
+ border-radius: 8px;
2590
+ background: transparent;
2591
+ color: #9ca3af;
2592
+ cursor: pointer;
2593
+ flex-shrink: 0;
2594
+ padding: 0;
2595
+ transition: color 0.15s, background 0.15s;
2596
+ }
2597
+
2598
+ .ai-attach-btn:hover:not(:disabled) {
2599
+ color: #4b5563;
2600
+ background: #f3f4f6;
2601
+ }
2602
+
2603
+ .ai-attach-btn:disabled {
2604
+ opacity: 0.4;
2605
+ cursor: not-allowed;
2606
+ }
2607
+
2372
2608
  .ai-chat-input-wrapper:focus-within {
2373
2609
  border-color: #1eaaf1;
2374
2610
  box-shadow: 0 0 0 3px rgba(30, 170, 241, 0.1);
@@ -2437,6 +2673,15 @@
2437
2673
  cursor: not-allowed;
2438
2674
  }
2439
2675
 
2676
+ .ai-chat-stop-btn {
2677
+ background: #ef4444;
2678
+ }
2679
+
2680
+ .ai-chat-stop-btn:hover {
2681
+ background: #dc2626;
2682
+ transform: scale(1.05);
2683
+ }
2684
+
2440
2685
  .ai-chat-disclaimer {
2441
2686
  font-size: 11px;
2442
2687
  color: #9ca3af;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchid-ai",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Shared Orchid AI chat UI and visualization components",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -1,26 +1,127 @@
1
- import React, { useState, useRef, useEffect } from "react";
1
+ import React, { useState, useRef, useEffect, useCallback } from "react";
2
2
 
3
- export default function ChatInput({ onSend, disabled, disabledReason = "", onDisabledClick }) {
3
+ const ACCEPTED_TYPES = 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain,text/csv,text/markdown';
4
+ const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
5
+ const MAX_NON_IMAGE_BYTES = 5 * 1024 * 1024;
6
+ const MAX_IMAGE_DIMENSION = 1568;
7
+ let _attachId = 0;
8
+
9
+ // Resize image to fit within MAX_IMAGE_DIMENSION on its longest side.
10
+ // Images already within the limit are returned unchanged.
11
+ // Oversized images are resampled and re-encoded as JPEG.
12
+ function resizeImageIfNeeded(dataUrl, origMediaType) {
13
+ return new Promise((resolve) => {
14
+ const img = new Image();
15
+ img.onload = () => {
16
+ const { naturalWidth: w, naturalHeight: h } = img;
17
+ if (Math.max(w, h) <= MAX_IMAGE_DIMENSION) {
18
+ resolve({ dataUrl, mediaType: origMediaType });
19
+ return;
20
+ }
21
+ const scale = MAX_IMAGE_DIMENSION / Math.max(w, h);
22
+ const nw = Math.max(1, Math.round(w * scale));
23
+ const nh = Math.max(1, Math.round(h * scale));
24
+ const canvas = document.createElement('canvas');
25
+ canvas.width = nw;
26
+ canvas.height = nh;
27
+ const ctx = canvas.getContext('2d');
28
+ ctx.fillStyle = '#ffffff';
29
+ ctx.fillRect(0, 0, nw, nh);
30
+ ctx.drawImage(img, 0, 0, nw, nh);
31
+ resolve({ dataUrl: canvas.toDataURL('image/jpeg', 0.85), mediaType: 'image/jpeg' });
32
+ };
33
+ img.onerror = () => resolve({ dataUrl, mediaType: origMediaType });
34
+ img.src = dataUrl;
35
+ });
36
+ }
37
+
38
+ function FileIcon() {
39
+ return (
40
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
41
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
42
+ <polyline points="13 2 13 9 20 9" />
43
+ </svg>
44
+ );
45
+ }
46
+
47
+ export default function ChatInput({ onSend, onStop, loading = false, disabled, disabledReason = "", onDisabledClick }) {
4
48
  const [text, setText] = useState("");
49
+ const [attachments, setAttachments] = useState([]);
5
50
  const textareaRef = useRef(null);
51
+ const fileInputRef = useRef(null);
6
52
  const showDisabledReason = Boolean(disabledReason);
53
+ const canSend = !disabled && (text.trim().length > 0 || attachments.length > 0);
7
54
 
8
55
  useEffect(() => {
9
- if (!disabled && textareaRef.current) {
10
- textareaRef.current.focus();
11
- }
56
+ if (!disabled && textareaRef.current) textareaRef.current.focus();
12
57
  }, [disabled]);
13
58
 
59
+ const readFile = useCallback((file) => new Promise((resolve, reject) => {
60
+ if (!file) { reject(new Error('No file')); return; }
61
+ const isImage = IMAGE_TYPES.has(file.type);
62
+ if (!isImage && file.size > MAX_NON_IMAGE_BYTES) {
63
+ reject(new Error(`${file.name || 'File'} exceeds 5 MB`));
64
+ return;
65
+ }
66
+ const reader = new FileReader();
67
+ reader.onload = async (e) => {
68
+ try {
69
+ const rawDataUrl = e.target.result;
70
+ let dataUrl = rawDataUrl;
71
+ let mediaType = file.type || 'application/octet-stream';
72
+ if (isImage) {
73
+ const resized = await resizeImageIfNeeded(rawDataUrl, file.type);
74
+ dataUrl = resized.dataUrl;
75
+ mediaType = resized.mediaType;
76
+ }
77
+ resolve({
78
+ id: `att-${++_attachId}`,
79
+ name: file.name || (isImage ? 'pasted-image.png' : 'attachment'),
80
+ mediaType,
81
+ data: dataUrl.split(',')[1],
82
+ preview: isImage ? dataUrl : null,
83
+ size: file.size,
84
+ });
85
+ } catch (err) {
86
+ reject(err);
87
+ }
88
+ };
89
+ reader.onerror = reject;
90
+ reader.readAsDataURL(file);
91
+ }), []);
92
+
93
+ const addFiles = useCallback(async (fileList) => {
94
+ const files = Array.from(fileList).filter(Boolean);
95
+ if (!files.length) return;
96
+ const results = await Promise.allSettled(files.map(readFile));
97
+ const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
98
+ if (ok.length) setAttachments(prev => [...prev, ...ok]);
99
+ }, [readFile]);
100
+
101
+ const handleFileChange = (e) => {
102
+ if (e.target.files?.length) addFiles(e.target.files);
103
+ e.target.value = '';
104
+ };
105
+
106
+ const handlePaste = useCallback((e) => {
107
+ const items = Array.from(e.clipboardData?.items ?? []);
108
+ const fileItems = items.filter(it => it.kind === 'file');
109
+ if (!fileItems.length) return;
110
+ e.preventDefault();
111
+ addFiles(fileItems.map(it => it.getAsFile()));
112
+ }, [addFiles]);
113
+
114
+ const removeAttachment = useCallback((id) => {
115
+ setAttachments(prev => prev.filter(a => a.id !== id));
116
+ }, []);
117
+
14
118
  const handleSubmit = (e) => {
15
119
  e.preventDefault();
16
- const trimmed = text.trim();
17
- if (trimmed && !disabled) {
18
- onSend(trimmed);
19
- setText("");
20
- if (textareaRef.current) {
21
- textareaRef.current.style.height = "auto";
22
- }
23
- }
120
+ if (!canSend) return;
121
+ onSend(text.trim(), attachments.length ? attachments : undefined);
122
+ setText("");
123
+ setAttachments([]);
124
+ if (textareaRef.current) textareaRef.current.style.height = "auto";
24
125
  };
25
126
 
26
127
  const handleKeyDown = (e) => {
@@ -31,17 +132,14 @@ export default function ChatInput({ onSend, disabled, disabledReason = "", onDis
31
132
  };
32
133
 
33
134
  const handleInput = (e) => {
34
- const textarea = e.target;
35
- textarea.style.height = "auto";
36
- textarea.style.height = Math.min(textarea.scrollHeight, 150) + "px";
37
- setText(textarea.value);
135
+ const ta = e.target;
136
+ ta.style.height = "auto";
137
+ ta.style.height = Math.min(ta.scrollHeight, 150) + "px";
138
+ setText(ta.value);
38
139
  };
39
140
 
40
141
  const handleDisabledInteraction = (e) => {
41
- if (!disabled || !onDisabledClick) {
42
- return;
43
- }
44
-
142
+ if (!disabled || !onDisabledClick) return;
45
143
  e.preventDefault();
46
144
  onDisabledClick();
47
145
  };
@@ -49,7 +147,7 @@ export default function ChatInput({ onSend, disabled, disabledReason = "", onDis
49
147
  return (
50
148
  <form className="ai-chat-input-form" onSubmit={handleSubmit}>
51
149
  <div
52
- className={`ai-chat-input-wrapper ${showDisabledReason ? "disabled-with-reason" : ""}`}
150
+ className={`ai-chat-input-wrapper${showDisabledReason ? " disabled-with-reason" : ""}`}
53
151
  onMouseDown={handleDisabledInteraction}
54
152
  >
55
153
  {showDisabledReason && (
@@ -61,27 +159,85 @@ export default function ChatInput({ onSend, disabled, disabledReason = "", onDis
61
159
  aria-label={disabledReason}
62
160
  />
63
161
  )}
64
- <textarea
65
- ref={textareaRef}
66
- className="ai-chat-input"
67
- value={text}
68
- onChange={handleInput}
69
- onKeyDown={handleKeyDown}
70
- placeholder={showDisabledReason ? "Select a client to start chatting..." : "Type your question..."}
71
- disabled={disabled}
72
- rows={1}
73
- />
74
- <button
75
- type="submit"
76
- className="ai-chat-send-btn"
77
- disabled={disabled || !text.trim()}
78
- title="Send message"
79
- >
80
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
81
- <line x1="22" y1="2" x2="11" y2="13" />
82
- <polygon points="22 2 15 22 11 13 2 9 22 2" />
83
- </svg>
84
- </button>
162
+ {attachments.length > 0 && (
163
+ <div className="ai-attach-strip">
164
+ {attachments.map((att) => (
165
+ <div key={att.id} className={`ai-attach-chip${att.preview ? " ai-attach-chip--img" : ""}`}>
166
+ {att.preview
167
+ ? <img src={att.preview} className="ai-attach-thumb" alt={att.name} />
168
+ : <span className="ai-attach-chip-icon"><FileIcon /></span>
169
+ }
170
+ <span className="ai-attach-chip-name">{att.name}</span>
171
+ <button
172
+ type="button"
173
+ className="ai-attach-chip-remove"
174
+ onClick={() => removeAttachment(att.id)}
175
+ aria-label={`Remove ${att.name}`}
176
+ >×</button>
177
+ </div>
178
+ ))}
179
+ </div>
180
+ )}
181
+ <div className="ai-chat-input-row">
182
+ <input
183
+ ref={fileInputRef}
184
+ type="file"
185
+ accept={ACCEPTED_TYPES}
186
+ multiple
187
+ className="ai-attach-file-input"
188
+ onChange={handleFileChange}
189
+ tabIndex={-1}
190
+ aria-hidden="true"
191
+ />
192
+ <button
193
+ type="button"
194
+ className="ai-attach-btn"
195
+ onClick={() => fileInputRef.current?.click()}
196
+ disabled={disabled}
197
+ title="Attach file or image"
198
+ aria-label="Attach file or image"
199
+ >
200
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
201
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
202
+ </svg>
203
+ </button>
204
+ <textarea
205
+ ref={textareaRef}
206
+ className="ai-chat-input"
207
+ value={text}
208
+ onChange={handleInput}
209
+ onKeyDown={handleKeyDown}
210
+ onPaste={handlePaste}
211
+ placeholder={showDisabledReason ? "Select a client to start chatting..." : "Type your question..."}
212
+ disabled={disabled}
213
+ rows={1}
214
+ />
215
+ {loading ? (
216
+ <button
217
+ type="button"
218
+ className="ai-chat-send-btn ai-chat-stop-btn"
219
+ onClick={onStop}
220
+ title="Stop generating"
221
+ aria-label="Stop generating"
222
+ >
223
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
224
+ <rect x="2" y="2" width="12" height="12" rx="2" />
225
+ </svg>
226
+ </button>
227
+ ) : (
228
+ <button
229
+ type="submit"
230
+ className="ai-chat-send-btn"
231
+ disabled={!canSend}
232
+ title="Send message"
233
+ >
234
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
235
+ <line x1="22" y1="2" x2="11" y2="13" />
236
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
237
+ </svg>
238
+ </button>
239
+ )}
240
+ </div>
85
241
  </div>
86
242
  <p className="ai-chat-disclaimer">
87
243
  Responses are generated by AI and may contain errors. Double-check anything important. Data in the app is
@@ -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
  );