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.
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;
@@ -827,6 +860,39 @@
827
860
  color: #1f2937;
828
861
  }
829
862
 
863
+ .ai-chart-card-header {
864
+ display: flex;
865
+ align-items: center;
866
+ justify-content: space-between;
867
+ gap: 12px;
868
+ margin-bottom: 10px;
869
+ }
870
+
871
+ .ai-chart-card-header .ai-chart-title {
872
+ margin: 0;
873
+ }
874
+
875
+ .ai-chart-csv-btn {
876
+ display: inline-flex;
877
+ align-items: center;
878
+ gap: 4px;
879
+ flex-shrink: 0;
880
+ padding: 4px 10px;
881
+ font-size: 12px;
882
+ font-weight: 500;
883
+ color: #1eaaf1;
884
+ background: #ffffff;
885
+ border: 1px solid #d1d5db;
886
+ border-radius: 6px;
887
+ cursor: pointer;
888
+ transition: background 0.15s, border-color 0.15s;
889
+ }
890
+
891
+ .ai-chart-csv-btn:hover {
892
+ background: #f0f9ff;
893
+ border-color: #1eaaf1;
894
+ }
895
+
830
896
  .ai-chart-error {
831
897
  color: #b91c1c;
832
898
  background: #fef2f2;
@@ -1844,8 +1910,9 @@
1844
1910
  font-size: 22px;
1845
1911
  font-weight: 700;
1846
1912
  color: #1f2937;
1847
- line-height: 1;
1913
+ line-height: 1.15;
1848
1914
  margin-bottom: 5px;
1915
+ overflow-wrap: break-word;
1849
1916
  }
1850
1917
 
1851
1918
  .ai-stat-unit {
@@ -2183,6 +2250,23 @@
2183
2250
  word-break: break-word;
2184
2251
  }
2185
2252
 
2253
+ .ai-chat-process-url {
2254
+ color: var(--ai-accent, #3b82f6);
2255
+ text-decoration: none;
2256
+ }
2257
+ .ai-chat-process-url:hover {
2258
+ text-decoration: underline;
2259
+ }
2260
+
2261
+ .ai-chat-process-url-list {
2262
+ list-style: none;
2263
+ margin: 0;
2264
+ padding: 0;
2265
+ display: flex;
2266
+ flex-direction: column;
2267
+ gap: 2px;
2268
+ }
2269
+
2186
2270
  /* ── Message Actions ── */
2187
2271
 
2188
2272
  .ai-chat-message-actions {
@@ -2279,6 +2363,74 @@
2279
2363
  height: 5px;
2280
2364
  }
2281
2365
 
2366
+ /* Queued (pending) messages — shown as muted user bubbles while a prior request runs */
2367
+
2368
+ .ai-chat-message--queued {
2369
+ animation: none;
2370
+ }
2371
+
2372
+ .ai-chat-bubble--queued {
2373
+ opacity: 0.72;
2374
+ display: flex;
2375
+ flex-direction: column;
2376
+ gap: 8px;
2377
+ }
2378
+
2379
+ .ai-chat-pending-msg-text {
2380
+ white-space: pre-wrap;
2381
+ word-break: break-word;
2382
+ }
2383
+
2384
+ .ai-chat-pending-footer {
2385
+ display: flex;
2386
+ align-items: center;
2387
+ gap: 8px;
2388
+ flex-wrap: wrap;
2389
+ }
2390
+
2391
+ .ai-chat-pending-status {
2392
+ font-size: 11px;
2393
+ color: rgba(255, 255, 255, 0.6);
2394
+ font-style: italic;
2395
+ flex: 1;
2396
+ min-width: 0;
2397
+ }
2398
+
2399
+ .ai-chat-pending-actions {
2400
+ display: flex;
2401
+ gap: 6px;
2402
+ flex-wrap: wrap;
2403
+ }
2404
+
2405
+ .ai-chat-pending-btn {
2406
+ font-size: 11px;
2407
+ font-weight: 500;
2408
+ padding: 3px 10px;
2409
+ border-radius: 5px;
2410
+ border: 1px solid rgba(255, 255, 255, 0.3);
2411
+ background: rgba(255, 255, 255, 0.15);
2412
+ color: rgba(255, 255, 255, 0.9);
2413
+ cursor: pointer;
2414
+ transition: background 0.15s, border-color 0.15s;
2415
+ }
2416
+
2417
+ .ai-chat-pending-btn:hover {
2418
+ background: rgba(255, 255, 255, 0.25);
2419
+ border-color: rgba(255, 255, 255, 0.5);
2420
+ }
2421
+
2422
+ .ai-chat-pending-btn--primary {
2423
+ background: rgba(255, 255, 255, 0.28);
2424
+ border-color: rgba(255, 255, 255, 0.55);
2425
+ color: #ffffff;
2426
+ font-weight: 600;
2427
+ }
2428
+
2429
+ .ai-chat-pending-btn--primary:hover {
2430
+ background: rgba(255, 255, 255, 0.42);
2431
+ border-color: rgba(255, 255, 255, 0.75);
2432
+ }
2433
+
2282
2434
  .ai-chat-streaming-status {
2283
2435
  display: flex;
2284
2436
  align-items: center;
@@ -2318,6 +2470,19 @@
2318
2470
  border: 1px solid #e5e7eb;
2319
2471
  }
2320
2472
 
2473
+ .ai-building-block--stopped {
2474
+ background: #fafafa;
2475
+ border-color: #e5e7eb;
2476
+ opacity: 0.65;
2477
+ }
2478
+
2479
+ .ai-chat-interrupted-notice {
2480
+ font-size: 12px;
2481
+ color: #9ca3af;
2482
+ font-style: italic;
2483
+ margin-top: 8px;
2484
+ }
2485
+
2321
2486
  .ai-building-block__label {
2322
2487
  font-size: 13px;
2323
2488
  font-weight: 600;
@@ -2357,18 +2522,123 @@
2357
2522
 
2358
2523
  .ai-chat-input-wrapper {
2359
2524
  display: flex;
2360
- align-items: center;
2525
+ flex-direction: column;
2361
2526
  position: relative;
2362
- gap: 10px;
2363
2527
  max-width: 768px;
2364
2528
  margin: 0 auto;
2365
2529
  background: #f9fafb;
2366
2530
  border: 1px solid #d1d5db;
2367
2531
  border-radius: 14px;
2368
- padding: 8px 8px 8px 16px;
2532
+ overflow: hidden;
2369
2533
  transition: border-color 0.2s, box-shadow 0.2s;
2370
2534
  }
2371
2535
 
2536
+ /* ── Attachment preview strip ── */
2537
+
2538
+ .ai-attach-strip {
2539
+ display: flex;
2540
+ flex-wrap: wrap;
2541
+ gap: 6px;
2542
+ padding: 10px 12px 8px;
2543
+ border-bottom: 1px solid #e5e7eb;
2544
+ }
2545
+
2546
+ .ai-attach-chip {
2547
+ display: flex;
2548
+ align-items: center;
2549
+ gap: 5px;
2550
+ padding: 4px 6px 4px 8px;
2551
+ background: #e5e7eb;
2552
+ border-radius: 8px;
2553
+ font-size: 12px;
2554
+ color: #374151;
2555
+ max-width: 180px;
2556
+ }
2557
+
2558
+ .ai-attach-chip--img {
2559
+ padding: 3px 6px 3px 3px;
2560
+ }
2561
+
2562
+ .ai-attach-thumb {
2563
+ width: 42px;
2564
+ height: 42px;
2565
+ object-fit: cover;
2566
+ border-radius: 6px;
2567
+ flex-shrink: 0;
2568
+ display: block;
2569
+ }
2570
+
2571
+ .ai-attach-chip-icon {
2572
+ display: flex;
2573
+ align-items: center;
2574
+ color: #6b7280;
2575
+ flex-shrink: 0;
2576
+ }
2577
+
2578
+ .ai-attach-chip-name {
2579
+ flex: 1;
2580
+ overflow: hidden;
2581
+ text-overflow: ellipsis;
2582
+ white-space: nowrap;
2583
+ min-width: 0;
2584
+ }
2585
+
2586
+ .ai-attach-chip-remove {
2587
+ border: none;
2588
+ background: none;
2589
+ color: #9ca3af;
2590
+ cursor: pointer;
2591
+ padding: 0 1px;
2592
+ font-size: 16px;
2593
+ line-height: 1;
2594
+ flex-shrink: 0;
2595
+ display: flex;
2596
+ align-items: center;
2597
+ }
2598
+
2599
+ .ai-attach-chip-remove:hover {
2600
+ color: #ef4444;
2601
+ }
2602
+
2603
+ /* ── Input row (attach btn + textarea + send btn) ── */
2604
+
2605
+ .ai-chat-input-row {
2606
+ display: flex;
2607
+ align-items: center;
2608
+ gap: 8px;
2609
+ padding: 8px 8px 8px 10px;
2610
+ }
2611
+
2612
+ .ai-attach-file-input {
2613
+ display: none;
2614
+ }
2615
+
2616
+ .ai-attach-btn {
2617
+ display: flex;
2618
+ align-items: center;
2619
+ justify-content: center;
2620
+ width: 32px;
2621
+ height: 32px;
2622
+ border: none;
2623
+ border-radius: 8px;
2624
+ background: transparent;
2625
+ color: #9ca3af;
2626
+ cursor: pointer;
2627
+ flex-shrink: 0;
2628
+ padding: 0;
2629
+ transition: color 0.15s, background 0.15s;
2630
+ }
2631
+
2632
+ .ai-attach-btn:hover:not(:disabled) {
2633
+ color: #4b5563;
2634
+ background: #f3f4f6;
2635
+ }
2636
+
2637
+ .ai-attach-btn:disabled {
2638
+ opacity: 0.4;
2639
+ cursor: not-allowed;
2640
+ }
2641
+
2372
2642
  .ai-chat-input-wrapper:focus-within {
2373
2643
  border-color: #1eaaf1;
2374
2644
  box-shadow: 0 0 0 3px rgba(30, 170, 241, 0.1);
@@ -2437,6 +2707,15 @@
2437
2707
  cursor: not-allowed;
2438
2708
  }
2439
2709
 
2710
+ .ai-chat-stop-btn {
2711
+ background: #ef4444;
2712
+ }
2713
+
2714
+ .ai-chat-stop-btn:hover {
2715
+ background: #dc2626;
2716
+ transform: scale(1.05);
2717
+ }
2718
+
2440
2719
  .ai-chat-disclaimer {
2441
2720
  font-size: 11px;
2442
2721
  color: #9ca3af;
@@ -2517,6 +2796,20 @@
2517
2796
  color: #1f2937 !important;
2518
2797
  box-shadow: none !important;
2519
2798
  }
2799
+
2800
+ body.ai-chat-printing #ai-cortex-print-section .ai-stat-card {
2801
+ padding: 8px 10px !important;
2802
+ min-width: 80px !important;
2803
+ }
2804
+
2805
+ body.ai-chat-printing #ai-cortex-print-section .ai-stat-value {
2806
+ font-size: 17px !important;
2807
+ line-height: 1.2 !important;
2808
+ }
2809
+
2810
+ body.ai-chat-printing #ai-cortex-print-section .ai-stat-label {
2811
+ font-size: 9px !important;
2812
+ }
2520
2813
  }
2521
2814
 
2522
2815
  /* ── Responsive ── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchid-ai",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
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