@sybilion/uilib 1.2.18 → 1.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +1 -1
  2. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +4 -3
  3. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.styl.js +2 -2
  4. package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +11 -0
  5. package/dist/esm/components/ui/Chat/ChatMessage/icons/CsvIcon.js +8 -0
  6. package/dist/esm/components/ui/Chat/chat-preset-utils.js +12 -3
  7. package/dist/esm/contexts/chat-context.js +14 -4
  8. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +14 -0
  9. package/dist/esm/types/src/components/ui/Chat/ChatMessage/ChatMessage.d.ts +1 -1
  10. package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.d.ts +4 -0
  11. package/dist/esm/types/src/components/ui/Chat/ChatMessage/icons/CsvIcon.d.ts +3 -0
  12. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -1
  13. package/dist/esm/types/src/contexts/chat-context.d.ts +11 -7
  14. package/dist/esm/types/src/docs/pages/ChatUserCsvAttachmentPage.d.ts +1 -0
  15. package/dist/esm/types/src/utils/downloadTextFile.d.ts +2 -0
  16. package/dist/esm/utils/downloadTextFile.js +14 -0
  17. package/package.json +1 -1
  18. package/src/components/ui/Chat/Chat.types.ts +16 -0
  19. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +1 -0
  20. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl +67 -0
  21. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl.d.ts +6 -0
  22. package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +8 -1
  23. package/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.tsx +36 -0
  24. package/src/components/ui/Chat/ChatMessage/icons/CsvIcon.tsx +7 -0
  25. package/src/components/ui/Chat/chat-preset-utils.ts +12 -6
  26. package/src/components/ui/Chat/index.ts +3 -1
  27. package/src/contexts/chat-context.tsx +39 -8
  28. package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +171 -0
  29. package/src/docs/registry.ts +6 -0
  30. package/src/utils/downloadTextFile.ts +16 -0
@@ -30,7 +30,7 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
30
30
  }, [isEmpty, messages.length]);
31
31
  return (jsxs("div", { className: S.root, children: [showResizeHandle && resizeHandle ? (jsx(PanelResizeHandle, { edge: "leading", isActive: resizeHandle.isActive, startWidthPx: resizeHandle.startWidthPx, getShellWidth: resizeHandle.getShellWidth, onDragWidth: resizeHandle.onDragWidth, onDragComplete: resizeHandle.onDragComplete, className: cn(SidebarStem.sidebarResizeHandle, S.chatResizeHandle) })) : null, jsx("div", { className: S.panelHeader, children: onClose ? (jsx(Button, { type: "button", variant: "ghost", icon: true, className: S.panelClose, "aria-label": "Close chat", onClick: onClose, children: jsx(X, { className: "size-4" }) })) : null }), jsx("div", { className: S.content, children: jsxs(Chat, { isEmpty: isEmpty, scopeId: effectiveScopeId, onChatDeleted: onChatDeleted, children: [isEmpty ? (jsxs(Fragment, { children: [jsx(Chat.EmptyState, { ...emptyState }), renderPresets('fixed')] })) : (jsx("div", { className: S.scrollWrapper, children: jsxs(Scroll, { y: true, yScrollbarClassName: S.scrollbar, className: S.scroll, innerClassName: S.scrollInner, offset: { y: { before: 56, after: 180 } }, fadeSize: "m", autoHide: true, ref: scrollRef, children: [messages.map((msg, index, arr) => {
32
32
  const isLast = index === arr.length - 1;
33
- return (jsx(Chat.Message, { role: msg.role, text: msg.text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
33
+ return (jsx(Chat.Message, { role: msg.role, text: msg.text, userCsvAttachment: msg.userCsvAttachment, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
34
34
  ? { label: scriptContinueLabel }
35
35
  : undefined, onScriptContinue: isLast && scriptContinueLabel
36
36
  ? onScriptContinue
@@ -1,14 +1,15 @@
1
- import { jsx } from 'react/jsx-runtime';
1
+ import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import cn from 'classnames';
3
3
  import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
4
4
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
5
5
  import { AgentMessageContent } from './AgentMessageContent.js';
6
6
  import S from './ChatMessage.styl.js';
7
+ import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble.js';
7
8
 
8
- function ChatMessage({ role, text, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
9
+ function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
9
10
  const isAssistant = role === MessageRole.ASSISTANT;
10
11
  const isSystem = role === MessageRole.SYSTEM;
11
- return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsx("div", { className: S.text, children: text })) }));
12
+ return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: text }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
12
13
  }
13
14
 
14
15
  export { ChatMessage };
@@ -1,7 +1,7 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".ChatMessage_root__6rnsF{background:var(--bg-secondary);display:flex;flex-direction:column;gap:var(--p-1);padding:var(--p-6)}.ChatMessage_text__Y1XNR{color:var(--text-secondary);font-size:var(--text-sm);max-width:100%;-webkit-user-select:text;-moz-user-select:text;user-select:text;width:-moz-fit-content;width:fit-content}.ChatMessage_role-user__u4JPV{align-items:flex-end}.ChatMessage_role-user__u4JPV .ChatMessage_text__Y1XNR{background-color:var(--sb-slate-100);border-radius:var(--p-4);border-bottom-right-radius:0;padding:var(--p-3) var(--p-4);white-space:pre-wrap}.dark .ChatMessage_role-user__u4JPV .ChatMessage_text__Y1XNR{background-color:var(--sb-gray-800)}.ChatMessage_role-system__g13OP{align-items:center}.ChatMessage_role-system__g13OP .ChatMessage_text__Y1XNR{color:var(--muted-foreground);font-size:var(--text-xs);width:100%}.ChatMessage_role-assistant__wketE .ChatMessage_text__Y1XNR{width:100%}.ChatMessage_role-assistant__wketE h3{line-height:2.4}.ChatMessage_role-assistant__wketE h4{font-size:1.1em;font-weight:600;line-height:2.2}.ChatMessage_role-assistant__wketE .ChatMessage_bullet__6vAhq{display:inline-block;margin-left:4px;margin-right:6px}.ChatMessage_role-assistant__wketE .ChatMessage_bullet__6vAhq:before{color:var(--text-secondary);content:\"•\";display:inline-block}.ChatMessage_role-assistant__wketE .ChatMessage_scrollHorizontal__Rms9n{max-width:100%}.ChatMessage_role-assistant__wketE table{border:1px solid var(--border);border-collapse:collapse;border-radius:var(--p-2);border-spacing:0;margin:var(--p-4) 0;overflow:hidden}.ChatMessage_role-assistant__wketE table td,.ChatMessage_role-assistant__wketE table th{border:1px solid var(--border);min-width:100px;padding:var(--p-1)}.ChatMessage_role-assistant__wketE table th{text-align:left}.ChatMessage_role-assistant__wketE ol,.ChatMessage_role-assistant__wketE ul{padding-left:var(--p-4)}.ChatMessage_role-assistant__wketE ul{list-style-type:disc}.ChatMessage_role-assistant__wketE ol{list-style-type:decimal}.ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T{align-items:center;border:1px dashed var(--sb-slate-300);border-radius:8px;color:var(--foreground);display:inline-flex;font-size:var(--text-xs);gap:6px;margin:1px;max-width:100%;padding:2px 6px 2px 4px;text-decoration:none;vertical-align:middle}.ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T:hover{background-color:var(--sb-slate-50);border-color:var(--sb-slate-400);border-style:solid}.dark .ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T{border-color:var(--sb-gray-600)}.dark .ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T:hover{background-color:var(--sb-gray-900)}.ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLinkLabel__PMU7e{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ChatMessage_role-assistant__wketE .ChatMessage_quickReplyWrap__1UFyD{display:inline-block;margin:var(--p-1) var(--p-1) var(--p-1) 0;vertical-align:middle}.ChatMessage_role-assistant__wketE .ChatMessage_downloadButtons__RygM-{display:flex;gap:var(--p-2);margin-top:var(--p-4)}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa{align-items:center;background-color:var(--background);border-radius:var(--p-3);box-shadow:0 0 0 1px var(--border);cursor:pointer;display:flex;gap:var(--p-4);margin-top:var(--p-3);padding:var(--p-3);padding-right:var(--p-4);transition:all .15s;width:-moz-fit-content;width:fit-content}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa:hover{background-color:var(--sb-gray-50);border-color:var(--border)}.dark .ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa{background-color:var(--sb-gray-900);border-color:var(--border)}.dark .ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa:hover{background-color:var(--sb-gray-800)}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardIcon__jkxDJ{align-items:center;border-radius:var(--p-2);display:flex;flex-shrink:0;height:32px;justify-content:center;width:32px}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardContent__PTPwz{display:flex;flex:1;flex-direction:column;min-width:0}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardTitle__K1wqr{font-size:var(--text-base);font-weight:600;line-height:1.4}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardSubtitle__fVeF2{color:var(--muted-foreground);font-size:var(--text-sm);line-height:1.4}";
4
- var S = {"root":"ChatMessage_root__6rnsF","text":"ChatMessage_text__Y1XNR","role-user":"ChatMessage_role-user__u4JPV","role-system":"ChatMessage_role-system__g13OP","role-assistant":"ChatMessage_role-assistant__wketE","bullet":"ChatMessage_bullet__6vAhq","scrollHorizontal":"ChatMessage_scrollHorizontal__Rms9n","datasetAppLink":"ChatMessage_datasetAppLink__Pxy-T","datasetAppLinkLabel":"ChatMessage_datasetAppLinkLabel__PMU7e","quickReplyWrap":"ChatMessage_quickReplyWrap__1UFyD","downloadButtons":"ChatMessage_downloadButtons__RygM-","downloadCard":"ChatMessage_downloadCard__NsNRa","downloadCardIcon":"ChatMessage_downloadCardIcon__jkxDJ","downloadCardContent":"ChatMessage_downloadCardContent__PTPwz","downloadCardTitle":"ChatMessage_downloadCardTitle__K1wqr","downloadCardSubtitle":"ChatMessage_downloadCardSubtitle__fVeF2"};
3
+ var css_248z = ".ChatMessage_root__6rnsF{background:var(--bg-secondary);display:flex;flex-direction:column;gap:var(--p-1);padding:var(--p-6)}.ChatMessage_text__Y1XNR{color:var(--text-secondary);font-size:var(--text-sm);max-width:100%;-webkit-user-select:text;-moz-user-select:text;user-select:text;width:-moz-fit-content;width:fit-content}.ChatMessage_role-user__u4JPV{align-items:flex-end}.ChatMessage_role-user__u4JPV .ChatMessage_userColumn__cQM6-{align-items:flex-end;display:flex;flex-direction:column;gap:var(--p-2);max-width:100%}.ChatMessage_role-user__u4JPV .ChatMessage_text__Y1XNR{background-color:var(--sb-slate-100);border-radius:var(--p-4);border-bottom-right-radius:0;padding:var(--p-3) var(--p-4);white-space:pre-wrap}.dark .ChatMessage_role-user__u4JPV .ChatMessage_text__Y1XNR{background-color:var(--sb-gray-800)}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCard__D1M7y{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--sb-slate-100);border:0;border-radius:var(--p-4);border-bottom-right-radius:0;box-shadow:0 0 0 1px var(--border);color:var(--sb-green-600);cursor:pointer;display:flex;font:inherit;gap:var(--p-4);margin:0;max-width:100%;padding:var(--p-3);padding-right:var(--p-4);text-align:left;transition:background-color .15s;width:-moz-fit-content;width:fit-content}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCard__D1M7y:hover{background-color:var(--sb-gray-50)}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCard__D1M7y:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.dark .ChatMessage_role-user__u4JPV .ChatMessage_userCsvCard__D1M7y{background-color:var(--sb-gray-800);color:var(--sb-green-400)}.dark .ChatMessage_role-user__u4JPV .ChatMessage_userCsvCard__D1M7y:hover{background-color:var(--sb-gray-900)}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCardIcon__0-KS6{align-items:center;display:flex;flex-shrink:0;height:32px;justify-content:center;width:32px}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCardContent__LoMGE{display:flex;flex:1;flex-direction:column;min-width:0}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCardTitle__9W76E{color:var(--text-secondary);font-size:var(--text-base);font-weight:600;line-height:1.4}.ChatMessage_role-user__u4JPV .ChatMessage_userCsvCardSubtitle__YZeHv{color:var(--muted-foreground);font-size:var(--text-sm);line-height:1.4}.ChatMessage_role-system__g13OP{align-items:center}.ChatMessage_role-system__g13OP .ChatMessage_text__Y1XNR{color:var(--muted-foreground);font-size:var(--text-xs);width:100%}.ChatMessage_role-assistant__wketE .ChatMessage_text__Y1XNR{width:100%}.ChatMessage_role-assistant__wketE h3{line-height:2.4}.ChatMessage_role-assistant__wketE h4{font-size:1.1em;font-weight:600;line-height:2.2}.ChatMessage_role-assistant__wketE .ChatMessage_bullet__6vAhq{display:inline-block;margin-left:4px;margin-right:6px}.ChatMessage_role-assistant__wketE .ChatMessage_bullet__6vAhq:before{color:var(--text-secondary);content:\"•\";display:inline-block}.ChatMessage_role-assistant__wketE .ChatMessage_scrollHorizontal__Rms9n{max-width:100%}.ChatMessage_role-assistant__wketE table{border:1px solid var(--border);border-collapse:collapse;border-radius:var(--p-2);border-spacing:0;margin:var(--p-4) 0;overflow:hidden}.ChatMessage_role-assistant__wketE table td,.ChatMessage_role-assistant__wketE table th{border:1px solid var(--border);min-width:100px;padding:var(--p-1)}.ChatMessage_role-assistant__wketE table th{text-align:left}.ChatMessage_role-assistant__wketE ol,.ChatMessage_role-assistant__wketE ul{padding-left:var(--p-4)}.ChatMessage_role-assistant__wketE ul{list-style-type:disc}.ChatMessage_role-assistant__wketE ol{list-style-type:decimal}.ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T{align-items:center;border:1px dashed var(--sb-slate-300);border-radius:8px;color:var(--foreground);display:inline-flex;font-size:var(--text-xs);gap:6px;margin:1px;max-width:100%;padding:2px 6px 2px 4px;text-decoration:none;vertical-align:middle}.ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T:hover{background-color:var(--sb-slate-50);border-color:var(--sb-slate-400);border-style:solid}.dark .ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T{border-color:var(--sb-gray-600)}.dark .ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLink__Pxy-T:hover{background-color:var(--sb-gray-900)}.ChatMessage_role-assistant__wketE .ChatMessage_datasetAppLinkLabel__PMU7e{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ChatMessage_role-assistant__wketE .ChatMessage_quickReplyWrap__1UFyD{display:inline-block;margin:var(--p-1) var(--p-1) var(--p-1) 0;vertical-align:middle}.ChatMessage_role-assistant__wketE .ChatMessage_downloadButtons__RygM-{display:flex;gap:var(--p-2);margin-top:var(--p-4)}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa{align-items:center;background-color:var(--background);border-radius:var(--p-3);box-shadow:0 0 0 1px var(--border);cursor:pointer;display:flex;gap:var(--p-4);margin-top:var(--p-3);padding:var(--p-3);padding-right:var(--p-4);transition:all .15s;width:-moz-fit-content;width:fit-content}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa:hover{background-color:var(--sb-gray-50);border-color:var(--border)}.dark .ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa{background-color:var(--sb-gray-900);border-color:var(--border)}.dark .ChatMessage_role-assistant__wketE .ChatMessage_downloadCard__NsNRa:hover{background-color:var(--sb-gray-800)}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardIcon__jkxDJ{align-items:center;border-radius:var(--p-2);display:flex;flex-shrink:0;height:32px;justify-content:center;width:32px}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardContent__PTPwz{display:flex;flex:1;flex-direction:column;min-width:0}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardTitle__K1wqr{font-size:var(--text-base);font-weight:600;line-height:1.4}.ChatMessage_role-assistant__wketE .ChatMessage_downloadCardSubtitle__fVeF2{color:var(--muted-foreground);font-size:var(--text-sm);line-height:1.4}";
4
+ var S = {"root":"ChatMessage_root__6rnsF","text":"ChatMessage_text__Y1XNR","role-user":"ChatMessage_role-user__u4JPV","userColumn":"ChatMessage_userColumn__cQM6-","userCsvCard":"ChatMessage_userCsvCard__D1M7y","userCsvCardIcon":"ChatMessage_userCsvCardIcon__0-KS6","userCsvCardContent":"ChatMessage_userCsvCardContent__LoMGE","userCsvCardTitle":"ChatMessage_userCsvCardTitle__9W76E","userCsvCardSubtitle":"ChatMessage_userCsvCardSubtitle__YZeHv","role-system":"ChatMessage_role-system__g13OP","role-assistant":"ChatMessage_role-assistant__wketE","bullet":"ChatMessage_bullet__6vAhq","scrollHorizontal":"ChatMessage_scrollHorizontal__Rms9n","datasetAppLink":"ChatMessage_datasetAppLink__Pxy-T","datasetAppLinkLabel":"ChatMessage_datasetAppLinkLabel__PMU7e","quickReplyWrap":"ChatMessage_quickReplyWrap__1UFyD","downloadButtons":"ChatMessage_downloadButtons__RygM-","downloadCard":"ChatMessage_downloadCard__NsNRa","downloadCardIcon":"ChatMessage_downloadCardIcon__jkxDJ","downloadCardContent":"ChatMessage_downloadCardContent__PTPwz","downloadCardTitle":"ChatMessage_downloadCardTitle__K1wqr","downloadCardSubtitle":"ChatMessage_downloadCardSubtitle__fVeF2"};
5
5
  styleInject(css_248z);
6
6
 
7
7
  export { S as default };
@@ -0,0 +1,11 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { downloadTextFile } from '../../../../utils/downloadTextFile.js';
3
+ import { CsvIcon } from './icons/CsvIcon.js';
4
+ import S from './ChatMessage.styl.js';
5
+
6
+ const CSV_DOWNLOAD_HINT = 'Download .CSV file';
7
+ function UserCsvAttachmentBubble({ attachment, }) {
8
+ return (jsxs("button", { type: "button", className: S.userCsvCard, "aria-label": `${CSV_DOWNLOAD_HINT}: ${attachment.displayName}`, onClick: () => downloadTextFile(attachment.content, attachment.filename, 'text/csv;charset=utf-8'), children: [jsx("div", { className: S.userCsvCardIcon, children: jsx(CsvIcon, { size: 32 }) }), jsxs("div", { className: S.userCsvCardContent, children: [jsx("div", { className: S.userCsvCardTitle, children: attachment.displayName }), jsx("div", { className: S.userCsvCardSubtitle, children: CSV_DOWNLOAD_HINT })] })] }));
9
+ }
10
+
11
+ export { UserCsvAttachmentBubble };
@@ -0,0 +1,8 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { FileSpreadsheet } from 'lucide-react';
3
+
4
+ function CsvIcon({ size = 32 }) {
5
+ return (jsx(FileSpreadsheet, { size: size, "aria-hidden": true, strokeWidth: 1.75, color: "currentColor" }));
6
+ }
7
+
8
+ export { CsvIcon };
@@ -8,14 +8,23 @@ function formatChatTranscript(messages) {
8
8
  function normalizePresetMatchText(s) {
9
9
  return s.trim().normalize('NFC');
10
10
  }
11
+ function presetMatchesUserText(presetText, userTextNorm) {
12
+ const presetNorm = normalizePresetMatchText(presetText);
13
+ if (!presetNorm)
14
+ return false;
15
+ if (userTextNorm === presetNorm)
16
+ return true;
17
+ const prefix = `${presetNorm} `;
18
+ return userTextNorm.startsWith(prefix);
19
+ }
11
20
  function usedPresetIdsFromMessages(messages, presets) {
12
21
  if (!messages?.length || !presets?.length)
13
22
  return [];
14
- const userTexts = new Set(messages
23
+ const userTexts = messages
15
24
  .filter(m => m.role === MessageRole.USER)
16
- .map(m => normalizePresetMatchText(m.text)));
25
+ .map(m => normalizePresetMatchText(m.text));
17
26
  return presets
18
- .filter(p => userTexts.has(normalizePresetMatchText(p.text)))
27
+ .filter(p => userTexts.some(ut => presetMatchesUserText(p.text, ut)))
19
28
  .map(p => p.id);
20
29
  }
21
30
 
@@ -152,16 +152,18 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
152
152
  return { ...prev, [scopeId]: updatedChats };
153
153
  });
154
154
  }, []);
155
- const addMessage = useCallback((scopeId, chatId, role, text) => {
155
+ const addMessage = useCallback((scopeId, chatId, role, text, options) => {
156
156
  if (userSwitchKey === null)
157
157
  return undefined;
158
158
  addScopeIdToRegistry(scopeId);
159
159
  const storedText = stripJsonDashboardFences(text);
160
+ const attachment = role === MessageRole.USER ? options?.userCsvAttachment : undefined;
160
161
  const newMessage = {
161
162
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
162
163
  role,
163
164
  text: storedText,
164
165
  timestamp: Date.now(),
166
+ ...(attachment ? { userCsvAttachment: attachment } : {}),
165
167
  };
166
168
  setChats(prev => {
167
169
  const scopeChats = prev[scopeId] ?? [];
@@ -200,9 +202,17 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
200
202
  if (targetChatId === null || targetChatId === '') {
201
203
  throw new Error('No chat selected');
202
204
  }
203
- addMessage(scopeId, targetChatId, MessageRole.USER, message);
205
+ const apiPayload = typeof message === 'string' ? message : message.apiMessage;
206
+ if (typeof message === 'string') {
207
+ addMessage(scopeId, targetChatId, MessageRole.USER, message);
208
+ }
209
+ else {
210
+ addMessage(scopeId, targetChatId, MessageRole.USER, message.displayText, {
211
+ userCsvAttachment: message.userCsvAttachment,
212
+ });
213
+ }
204
214
  try {
205
- const data = await sendChatMessageFn(message, targetChatId);
215
+ const data = await sendChatMessageFn(apiPayload, targetChatId);
206
216
  if (data.session_id && data.session_id !== targetChatId) {
207
217
  setChats(prev => {
208
218
  const scopeChats = prev[scopeId] ?? [];
@@ -283,7 +293,7 @@ function useChatsForScopeId(scopeId) {
283
293
  currentChatId,
284
294
  setCurrentChatId: (targetId) => setCurrentChatId(scopeId, targetId),
285
295
  newChat: () => newChat(scopeId),
286
- addMessage: (chatId, role, text) => addMessage(scopeId, chatId, role, text),
296
+ addMessage: (chatId, role, text, options) => addMessage(scopeId, chatId, role, text, options),
287
297
  removeMessageById: (chatId, messageId) => removeMessageById(scopeId, chatId, messageId),
288
298
  sendMessage: (message, chatId) => sendMessage(scopeId, message, chatId),
289
299
  deleteChat: (sessionId) => deleteChat(scopeId, sessionId),
@@ -7,11 +7,24 @@ export declare enum MessageRole {
7
7
  }
8
8
  /** System placeholder while dashboard generation runs (must match ChatSheet `addMessage` text). */
9
9
  export declare const GENERATING_DASHBOARD_SYSTEM_TEXT = "Generating dashboard\u2026";
10
+ /** USER-only: sample CSV attached to a preset message; shown as a file row + client-side download. */
11
+ export type UserCsvAttachment = {
12
+ displayName: string;
13
+ filename: string;
14
+ content: string;
15
+ };
16
+ /** Send full text to the chat API while showing `displayText` + optional CSV attachment in the UI. */
17
+ export type ChatSendMessagePayload = {
18
+ apiMessage: string;
19
+ displayText: string;
20
+ userCsvAttachment?: UserCsvAttachment;
21
+ };
10
22
  export interface Message {
11
23
  id: string;
12
24
  role: MessageRole;
13
25
  text: string;
14
26
  timestamp: number;
27
+ userCsvAttachment?: UserCsvAttachment;
15
28
  }
16
29
  export interface Chat {
17
30
  session_id: string;
@@ -57,6 +70,7 @@ export interface ChatPromptProps {
57
70
  export interface ChatMessageProps {
58
71
  role: MessageRole;
59
72
  text: string;
73
+ userCsvAttachment?: UserCsvAttachment;
60
74
  onQuickReply?: (branchKey: string, displayLabel: string) => void;
61
75
  /** Branch keys already taken (e.g. from chat history); hide quick-reply buttons for these. */
62
76
  suppressedQuickReplyKeys?: ReadonlySet<string>;
@@ -1,2 +1,2 @@
1
1
  import { type ChatMessageProps } from '../Chat.types';
2
- export declare function ChatMessage({ role, text, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import type { UserCsvAttachment } from '../Chat.types';
2
+ export declare function UserCsvAttachmentBubble({ attachment, }: {
3
+ attachment: UserCsvAttachment;
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,3 @@
1
+ export declare function CsvIcon({ size }: {
2
+ size?: number;
3
+ }): import("react/jsx-runtime").JSX.Element;
@@ -9,5 +9,5 @@ export type { UseChatPanelChromeModelInput, UseChatPanelChromeModelResult, } fro
9
9
  export { ChatMessage } from './ChatMessage';
10
10
  export { ChatPrompt } from './ChatPrompt';
11
11
  export { ChatPresets } from './ChatPresets';
12
- export type { Chat as ChatType, Message, ChatProps, ChatPreset as ChatPresetType, } from './Chat.types';
12
+ export type { Chat as ChatType, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserCsvAttachment, } from './Chat.types';
13
13
  export { MessageRole } from './Chat.types';
@@ -1,14 +1,18 @@
1
1
  import { ReactNode } from 'react';
2
- import { type Chat, MessageRole } from '#uilib/components/ui/Chat/Chat.types';
2
+ import { type Chat, type ChatSendMessagePayload, type UserCsvAttachment, MessageRole } from '#uilib/components/ui/Chat/Chat.types';
3
3
  import type { ChatResponse } from '#uilib/types/chat-api.types';
4
4
  export type SendChatMessageFn = (message: string, targetChatId: string) => Promise<ChatResponse>;
5
+ export type { ChatSendMessagePayload, UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
6
+ export type AddChatMessageOptions = {
7
+ userCsvAttachment?: UserCsvAttachment;
8
+ };
5
9
  export interface ChatContextType {
6
10
  /** Returns the new session id, or undefined if no user / not created. */
7
11
  newChat: (scopeId: string) => string | undefined;
8
12
  setCurrentChatId: (currScopeId: string, sessionId: string) => void;
9
- addMessage: (scopeId: string, chatId: string, role: MessageRole, text: string) => string | undefined;
13
+ addMessage: (scopeId: string, chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string | undefined;
10
14
  removeMessageById: (scopeId: string, chatId: string, messageId: string) => void;
11
- sendMessage: (scopeId: string, message: string, chatId?: string) => Promise<string>;
15
+ sendMessage: (scopeId: string, message: string | ChatSendMessagePayload, chatId?: string) => Promise<string>;
12
16
  getChatsForScopeId: (scopeId: string) => Chat[];
13
17
  getCurrentChatId: (scopeId: string) => string | null;
14
18
  deleteChat: (scopeId: string, sessionId: string) => void;
@@ -30,9 +34,9 @@ export declare function useChatsForScopeId(scopeId: string): {
30
34
  currentChatId: string;
31
35
  setCurrentChatId: (targetId: string) => void;
32
36
  newChat: () => string;
33
- addMessage: (chatId: string, role: MessageRole, text: string) => string;
37
+ addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
34
38
  removeMessageById: (chatId: string, messageId: string) => void;
35
- sendMessage: (message: string, chatId?: string) => Promise<string>;
39
+ sendMessage: (message: string | ChatSendMessagePayload, chatId?: string) => Promise<string>;
36
40
  deleteChat: (sessionId: string) => void;
37
41
  };
38
42
  /** @deprecated Use useChatsForScopeId */
@@ -42,9 +46,9 @@ export declare function useChatsForDataset(scopeId: string): {
42
46
  currentChatId: string;
43
47
  setCurrentChatId: (targetId: string) => void;
44
48
  newChat: () => string;
45
- addMessage: (chatId: string, role: MessageRole, text: string) => string;
49
+ addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
46
50
  removeMessageById: (chatId: string, messageId: string) => void;
47
- sendMessage: (message: string, chatId?: string) => Promise<string>;
51
+ sendMessage: (message: string | ChatSendMessagePayload, chatId?: string) => Promise<string>;
48
52
  deleteChat: (sessionId: string) => void;
49
53
  };
50
54
  export declare function useCurrentChat(scopeId: string): Chat;
@@ -0,0 +1 @@
1
+ export default function ChatUserCsvAttachmentPage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,2 @@
1
+ /** Trigger a browser download for text/binary content (no backend). */
2
+ export declare function downloadTextFile(content: string, filename: string, mimeType: string): void;
@@ -0,0 +1,14 @@
1
+ /** Trigger a browser download for text/binary content (no backend). */
2
+ function downloadTextFile(content, filename, mimeType) {
3
+ const blob = new Blob([content], { type: mimeType });
4
+ const url = URL.createObjectURL(blob);
5
+ const link = document.createElement('a');
6
+ link.href = url;
7
+ link.download = filename;
8
+ document.body.appendChild(link);
9
+ link.click();
10
+ document.body.removeChild(link);
11
+ URL.revokeObjectURL(url);
12
+ }
13
+
14
+ export { downloadTextFile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.2.18",
3
+ "version": "1.2.20",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -11,11 +11,26 @@ export enum MessageRole {
11
11
  /** System placeholder while dashboard generation runs (must match ChatSheet `addMessage` text). */
12
12
  export const GENERATING_DASHBOARD_SYSTEM_TEXT = 'Generating dashboard…';
13
13
 
14
+ /** USER-only: sample CSV attached to a preset message; shown as a file row + client-side download. */
15
+ export type UserCsvAttachment = {
16
+ displayName: string;
17
+ filename: string;
18
+ content: string;
19
+ };
20
+
21
+ /** Send full text to the chat API while showing `displayText` + optional CSV attachment in the UI. */
22
+ export type ChatSendMessagePayload = {
23
+ apiMessage: string;
24
+ displayText: string;
25
+ userCsvAttachment?: UserCsvAttachment;
26
+ };
27
+
14
28
  export interface Message {
15
29
  id: string;
16
30
  role: MessageRole;
17
31
  text: string;
18
32
  timestamp: number;
33
+ userCsvAttachment?: UserCsvAttachment;
19
34
  }
20
35
 
21
36
  export interface Chat {
@@ -66,6 +81,7 @@ export interface ChatPromptProps {
66
81
  export interface ChatMessageProps {
67
82
  role: MessageRole;
68
83
  text: string;
84
+ userCsvAttachment?: UserCsvAttachment;
69
85
  onQuickReply?: (branchKey: string, displayLabel: string) => void;
70
86
  /** Branch keys already taken (e.g. from chat history); hide quick-reply buttons for these. */
71
87
  suppressedQuickReplyKeys?: ReadonlySet<string>;
@@ -120,6 +120,7 @@ export function ChatChrome({
120
120
  key={msg.id}
121
121
  role={msg.role}
122
122
  text={msg.text}
123
+ userCsvAttachment={msg.userCsvAttachment}
123
124
  onQuickReply={onQuickReply}
124
125
  suppressedQuickReplyKeys={suppressedQuickReplyKeys}
125
126
  quickReplyDisabled={isLoading}
@@ -17,6 +17,13 @@
17
17
  .role-user
18
18
  align-items flex-end
19
19
 
20
+ .userColumn
21
+ display flex
22
+ flex-direction column
23
+ align-items flex-end
24
+ gap var(--p-2)
25
+ max-width 100%
26
+
20
27
  .text
21
28
  padding var(--p-3) var(--p-4)
22
29
 
@@ -29,6 +36,66 @@
29
36
  :global(.dark) &
30
37
  background-color var(--sb-gray-800)
31
38
 
39
+ .userCsvCard
40
+ appearance none
41
+ border 0
42
+ margin 0
43
+ font inherit
44
+ display flex
45
+ align-items center
46
+ gap var(--p-4)
47
+ padding var(--p-3)
48
+ padding-right var(--p-4)
49
+ background-color var(--sb-slate-100)
50
+ box-shadow 0 0 0 1px var(--border)
51
+ border-radius var(--p-4)
52
+ border-bottom-right-radius 0
53
+ width fit-content
54
+ max-width 100%
55
+ text-align left
56
+ cursor pointer
57
+ transition background-color 150ms
58
+ color var(--sb-green-600)
59
+
60
+ &:hover
61
+ background-color var(--sb-gray-50)
62
+
63
+ &:focus-visible
64
+ outline 2px solid var(--ring)
65
+ outline-offset 2px
66
+
67
+ :global(.dark) &
68
+ background-color var(--sb-gray-800)
69
+ color var(--sb-green-400)
70
+
71
+ &:hover
72
+ background-color var(--sb-gray-900)
73
+
74
+ .userCsvCardIcon
75
+ display flex
76
+ align-items center
77
+ justify-content center
78
+ width 32px
79
+ height 32px
80
+ flex-shrink 0
81
+
82
+ .userCsvCardContent
83
+ display flex
84
+ flex-direction column
85
+ flex 1
86
+ min-width 0
87
+
88
+ .userCsvCardTitle
89
+ font-size var(--text-base)
90
+ font-weight 600
91
+ line-height 1.4
92
+ color var(--text-secondary)
93
+
94
+ .userCsvCardSubtitle
95
+ font-size var(--text-sm)
96
+ color var(--muted-foreground)
97
+ line-height 1.4
98
+
32
99
  .role-system
33
100
  align-items center
34
101
 
@@ -17,6 +17,12 @@ interface CssExports {
17
17
  'root': string;
18
18
  'scrollHorizontal': string;
19
19
  'text': string;
20
+ 'userColumn': string;
21
+ 'userCsvCard': string;
22
+ 'userCsvCardContent': string;
23
+ 'userCsvCardIcon': string;
24
+ 'userCsvCardSubtitle': string;
25
+ 'userCsvCardTitle': string;
20
26
  }
21
27
  export const cssExports: CssExports;
22
28
  export default cssExports;
@@ -8,10 +8,12 @@ import {
8
8
  } from '../Chat.types';
9
9
  import { AgentMessageContent } from './AgentMessageContent';
10
10
  import S from './ChatMessage.styl';
11
+ import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble';
11
12
 
12
13
  export function ChatMessage({
13
14
  role,
14
15
  text,
16
+ userCsvAttachment,
15
17
  onQuickReply,
16
18
  suppressedQuickReplyKeys,
17
19
  quickReplyDisabled,
@@ -45,7 +47,12 @@ export function ChatMessage({
45
47
  renderMessageChart={renderMessageChart}
46
48
  />
47
49
  ) : (
48
- <div className={S.text}>{text}</div>
50
+ <div className={S.userColumn}>
51
+ <div className={S.text}>{text}</div>
52
+ {userCsvAttachment ? (
53
+ <UserCsvAttachmentBubble attachment={userCsvAttachment} />
54
+ ) : null}
55
+ </div>
49
56
  )}
50
57
  </div>
51
58
  );
@@ -0,0 +1,36 @@
1
+ import type { UserCsvAttachment } from '../Chat.types';
2
+ import { downloadTextFile } from '#uilib/utils/downloadTextFile';
3
+
4
+ import { CsvIcon } from './icons/CsvIcon';
5
+ import S from './ChatMessage.styl';
6
+
7
+ const CSV_DOWNLOAD_HINT = 'Download .CSV file';
8
+
9
+ export function UserCsvAttachmentBubble({
10
+ attachment,
11
+ }: {
12
+ attachment: UserCsvAttachment;
13
+ }) {
14
+ return (
15
+ <button
16
+ type="button"
17
+ className={S.userCsvCard}
18
+ aria-label={`${CSV_DOWNLOAD_HINT}: ${attachment.displayName}`}
19
+ onClick={() =>
20
+ downloadTextFile(
21
+ attachment.content,
22
+ attachment.filename,
23
+ 'text/csv;charset=utf-8',
24
+ )
25
+ }
26
+ >
27
+ <div className={S.userCsvCardIcon}>
28
+ <CsvIcon size={32} />
29
+ </div>
30
+ <div className={S.userCsvCardContent}>
31
+ <div className={S.userCsvCardTitle}>{attachment.displayName}</div>
32
+ <div className={S.userCsvCardSubtitle}>{CSV_DOWNLOAD_HINT}</div>
33
+ </div>
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,7 @@
1
+ import { FileSpreadsheet } from 'lucide-react';
2
+
3
+ export function CsvIcon({ size = 32 }: { size?: number }) {
4
+ return (
5
+ <FileSpreadsheet size={size} aria-hidden strokeWidth={1.75} color="currentColor" />
6
+ );
7
+ }
@@ -11,17 +11,23 @@ export function normalizePresetMatchText(s: string): string {
11
11
  return s.trim().normalize('NFC');
12
12
  }
13
13
 
14
+ function presetMatchesUserText(presetText: string, userTextNorm: string): boolean {
15
+ const presetNorm = normalizePresetMatchText(presetText);
16
+ if (!presetNorm) return false;
17
+ if (userTextNorm === presetNorm) return true;
18
+ const prefix = `${presetNorm} `;
19
+ return userTextNorm.startsWith(prefix);
20
+ }
21
+
14
22
  export function usedPresetIdsFromMessages(
15
23
  messages: Message[] | undefined,
16
24
  presets: ChatPreset[] | undefined,
17
25
  ): string[] {
18
26
  if (!messages?.length || !presets?.length) return [];
19
- const userTexts = new Set(
20
- messages
21
- .filter(m => m.role === MessageRole.USER)
22
- .map(m => normalizePresetMatchText(m.text)),
23
- );
27
+ const userTexts = messages
28
+ .filter(m => m.role === MessageRole.USER)
29
+ .map(m => normalizePresetMatchText(m.text));
24
30
  return presets
25
- .filter(p => userTexts.has(normalizePresetMatchText(p.text)))
31
+ .filter(p => userTexts.some(ut => presetMatchesUserText(p.text, ut)))
26
32
  .map(p => p.id);
27
33
  }
@@ -17,8 +17,10 @@ export { ChatPrompt } from './ChatPrompt';
17
17
  export { ChatPresets } from './ChatPresets';
18
18
  export type {
19
19
  Chat as ChatType,
20
- Message,
20
+ ChatSendMessagePayload,
21
21
  ChatProps,
22
22
  ChatPreset as ChatPresetType,
23
+ Message,
24
+ UserCsvAttachment,
23
25
  } from './Chat.types';
24
26
  export { MessageRole } from './Chat.types';
@@ -10,7 +10,9 @@ import {
10
10
 
11
11
  import {
12
12
  type Chat,
13
+ type ChatSendMessagePayload,
13
14
  type Message,
15
+ type UserCsvAttachment,
14
16
  MessageRole,
15
17
  } from '#uilib/components/ui/Chat/Chat.types';
16
18
  import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
@@ -22,9 +24,15 @@ export type SendChatMessageFn = (
22
24
  targetChatId: string,
23
25
  ) => Promise<ChatResponse>;
24
26
 
27
+ export type { ChatSendMessagePayload, UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
28
+
25
29
  const CHATS_PREFIX = 'chats-';
26
30
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
27
31
 
32
+ export type AddChatMessageOptions = {
33
+ userCsvAttachment?: UserCsvAttachment;
34
+ };
35
+
28
36
  export interface ChatContextType {
29
37
  /** Returns the new session id, or undefined if no user / not created. */
30
38
  newChat: (scopeId: string) => string | undefined;
@@ -34,6 +42,7 @@ export interface ChatContextType {
34
42
  chatId: string,
35
43
  role: MessageRole,
36
44
  text: string,
45
+ options?: AddChatMessageOptions,
37
46
  ) => string | undefined;
38
47
  removeMessageById: (
39
48
  scopeId: string,
@@ -42,7 +51,7 @@ export interface ChatContextType {
42
51
  ) => void;
43
52
  sendMessage: (
44
53
  scopeId: string,
45
- message: string,
54
+ message: string | ChatSendMessagePayload,
46
55
  chatId?: string,
47
56
  ) => Promise<string>;
48
57
  getChatsForScopeId: (scopeId: string) => Chat[];
@@ -239,15 +248,24 @@ export function ChatProvider({
239
248
  }, []);
240
249
 
241
250
  const addMessage = useCallback(
242
- (scopeId: string, chatId: string, role: MessageRole, text: string) => {
251
+ (
252
+ scopeId: string,
253
+ chatId: string,
254
+ role: MessageRole,
255
+ text: string,
256
+ options?: AddChatMessageOptions,
257
+ ) => {
243
258
  if (userSwitchKey === null) return undefined;
244
259
  addScopeIdToRegistry(scopeId);
245
260
  const storedText = stripJsonDashboardFences(text);
261
+ const attachment =
262
+ role === MessageRole.USER ? options?.userCsvAttachment : undefined;
246
263
  const newMessage: Message = {
247
264
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
248
265
  role,
249
266
  text: storedText,
250
267
  timestamp: Date.now(),
268
+ ...(attachment ? { userCsvAttachment: attachment } : {}),
251
269
  };
252
270
 
253
271
  setChats(prev => {
@@ -292,7 +310,7 @@ export function ChatProvider({
292
310
  const sendMessage = useCallback(
293
311
  async (
294
312
  scopeId: string,
295
- message: string,
313
+ message: string | ChatSendMessagePayload,
296
314
  chatId?: string,
297
315
  ): Promise<string> => {
298
316
  const targetChatId = chatId ?? getCurrentChatId(scopeId);
@@ -300,10 +318,19 @@ export function ChatProvider({
300
318
  throw new Error('No chat selected');
301
319
  }
302
320
 
303
- addMessage(scopeId, targetChatId, MessageRole.USER, message);
321
+ const apiPayload =
322
+ typeof message === 'string' ? message : message.apiMessage;
323
+
324
+ if (typeof message === 'string') {
325
+ addMessage(scopeId, targetChatId, MessageRole.USER, message);
326
+ } else {
327
+ addMessage(scopeId, targetChatId, MessageRole.USER, message.displayText, {
328
+ userCsvAttachment: message.userCsvAttachment,
329
+ });
330
+ }
304
331
 
305
332
  try {
306
- const data = await sendChatMessageFn(message, targetChatId);
333
+ const data = await sendChatMessageFn(apiPayload, targetChatId);
307
334
 
308
335
  if (data.session_id && data.session_id !== targetChatId) {
309
336
  setChats(prev => {
@@ -430,11 +457,15 @@ export function useChatsForScopeId(scopeId: string) {
430
457
  currentChatId,
431
458
  setCurrentChatId: (targetId: string) => setCurrentChatId(scopeId, targetId),
432
459
  newChat: () => newChat(scopeId),
433
- addMessage: (chatId: string, role: MessageRole, text: string) =>
434
- addMessage(scopeId, chatId, role, text),
460
+ addMessage: (
461
+ chatId: string,
462
+ role: MessageRole,
463
+ text: string,
464
+ options?: AddChatMessageOptions,
465
+ ) => addMessage(scopeId, chatId, role, text, options),
435
466
  removeMessageById: (chatId: string, messageId: string) =>
436
467
  removeMessageById(scopeId, chatId, messageId),
437
- sendMessage: (message: string, chatId?: string) =>
468
+ sendMessage: (message: string | ChatSendMessagePayload, chatId?: string) =>
438
469
  sendMessage(scopeId, message, chatId),
439
470
  deleteChat: (sessionId: string) => deleteChat(scopeId, sessionId),
440
471
  };
@@ -0,0 +1,171 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ import { Button } from '#uilib/components/ui/Button';
4
+ import {
5
+ ChatChrome,
6
+ type Message,
7
+ MessageRole,
8
+ } from '#uilib/components/ui/Chat';
9
+ import { PageContentSection } from '#uilib/components/ui/Page';
10
+ import { ScrollRef } from '@homecode/ui';
11
+
12
+ import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
13
+ import { DocsHeaderActions } from '../docsHeaderActions';
14
+
15
+ const NO_QUICK_REPLY_KEYS: ReadonlySet<string> = new Set();
16
+
17
+ const SAMPLE_CSV = `Name,Score
18
+ Alice,10
19
+ Bob,20`;
20
+
21
+ const ASSISTANT_ACK =
22
+ 'Received your message. Use the file row below the text bubble to download the CSV sample.';
23
+
24
+ const ASSISTANT_REPLY_TYPED =
25
+ 'Plain reply for typed messages. Use “Load preset-style…” for the CSV attachment demo.';
26
+
27
+ function makeMessage(role: MessageRole, text: string): Message {
28
+ return {
29
+ id: crypto.randomUUID(),
30
+ role,
31
+ text,
32
+ timestamp: Date.now(),
33
+ };
34
+ }
35
+
36
+ function makeUserMessageWithCsv(
37
+ displayText: string,
38
+ displayName: string,
39
+ ): Message {
40
+ return {
41
+ id: crypto.randomUUID(),
42
+ role: MessageRole.USER,
43
+ text: displayText,
44
+ timestamp: Date.now(),
45
+ userCsvAttachment: {
46
+ displayName,
47
+ filename: 'docs-sample.csv',
48
+ content: SAMPLE_CSV,
49
+ },
50
+ };
51
+ }
52
+
53
+ export default function ChatUserCsvAttachmentPage() {
54
+ const [messages, setMessages] = useState<Message[]>([]);
55
+ const [isLoading, setIsLoading] = useState(false);
56
+ const replyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
57
+ const scrollRef = useRef<ScrollRef>(null);
58
+
59
+ useEffect(() => {
60
+ return () => {
61
+ if (replyTimeoutRef.current != null) {
62
+ clearTimeout(replyTimeoutRef.current);
63
+ }
64
+ };
65
+ }, []);
66
+
67
+ const isEmpty = messages.length === 0 && !isLoading;
68
+ const isLastMessageFromUser =
69
+ messages.length > 0 &&
70
+ messages[messages.length - 1]?.role === MessageRole.USER;
71
+
72
+ const addCsvDemo = useCallback(() => {
73
+ if (isLoading) return;
74
+ const displayText =
75
+ 'Open procurement buys analysis "Am I overpaying on my open orders?" Open buys (sample)';
76
+
77
+ setMessages(prev => [
78
+ ...prev,
79
+ makeUserMessageWithCsv(displayText, 'Open buys (sample)'),
80
+ ]);
81
+ setIsLoading(true);
82
+
83
+ if (replyTimeoutRef.current != null) clearTimeout(replyTimeoutRef.current);
84
+
85
+ replyTimeoutRef.current = setTimeout(() => {
86
+ replyTimeoutRef.current = null;
87
+ setMessages(prev => [
88
+ ...prev,
89
+ makeMessage(MessageRole.ASSISTANT, ASSISTANT_ACK),
90
+ ]);
91
+ setIsLoading(false);
92
+ }, 500);
93
+ }, [isLoading]);
94
+
95
+ const onSubmit = useCallback(
96
+ (raw: string) => {
97
+ const text = raw.trim();
98
+ if (!text || isLoading) return;
99
+
100
+ setMessages(prev => [...prev, makeMessage(MessageRole.USER, text)]);
101
+ setIsLoading(true);
102
+
103
+ if (replyTimeoutRef.current != null) {
104
+ clearTimeout(replyTimeoutRef.current);
105
+ }
106
+ replyTimeoutRef.current = setTimeout(() => {
107
+ replyTimeoutRef.current = null;
108
+ setMessages(prev => [
109
+ ...prev,
110
+ makeMessage(MessageRole.ASSISTANT, ASSISTANT_REPLY_TYPED),
111
+ ]);
112
+ setIsLoading(false);
113
+ }, 900);
114
+ },
115
+ [isLoading],
116
+ );
117
+
118
+ return (
119
+ <>
120
+ <AppPageHeader
121
+ breadcrumbs={[{ label: 'Chat' }, { label: 'User CSV attachment' }]}
122
+ title="Chat — user CSV attachment"
123
+ subheader="USER bubble with separate downloadable CSV row (dashboard preset pattern)."
124
+ actions={<DocsHeaderActions />}
125
+ />
126
+ <PageContentSection>
127
+ <p style={{ marginBottom: 16 }}>
128
+ <Button
129
+ type="button"
130
+ variant="outline"
131
+ size="sm"
132
+ onClick={addCsvDemo}
133
+ >
134
+ Load preset-style message + CSV attachment
135
+ </Button>
136
+ </p>
137
+ <ChatChrome
138
+ showResizeHandle={false}
139
+ resizeHandle={undefined}
140
+ onClose={undefined}
141
+ isEmpty={isEmpty}
142
+ renderPresets={() => null}
143
+ messages={messages}
144
+ onQuickReply={() => {}}
145
+ suppressedQuickReplyKeys={NO_QUICK_REPLY_KEYS}
146
+ isLoading={isLoading}
147
+ scriptContinueLabel={undefined}
148
+ onScriptContinue={undefined}
149
+ showBranchActionsRow={false}
150
+ showSyntheticBranchButtons={false}
151
+ unusedBranchKeys={[]}
152
+ isScriptComplete={false}
153
+ onGenerateDashboard={undefined}
154
+ generatingDashboard={false}
155
+ onGenerateDashboardClick={() => {}}
156
+ showInlinePresets={false}
157
+ isLastMessageFromUser={isLastMessageFromUser}
158
+ scrollRef={scrollRef}
159
+ effectiveScopeId="docs-chat-user-csv"
160
+ onPromptSubmit={onSubmit}
161
+ onChatDeleted={() => {}}
162
+ emptyState={{
163
+ title: 'CSV attachment demo',
164
+ description:
165
+ 'Click the button above or send any message. The CSV row uses the same UI as dashboard presets.',
166
+ }}
167
+ />
168
+ </PageContentSection>
169
+ </>
170
+ );
171
+ }
@@ -96,6 +96,12 @@ export const DOC_REGISTRY: DocEntry[] = [
96
96
  section: 'Chat',
97
97
  load: () => import('./pages/ChatPage'),
98
98
  },
99
+ {
100
+ slug: 'chat-user-csv-attachment',
101
+ title: 'Chat user CSV attachment',
102
+ section: 'Chat',
103
+ load: () => import('./pages/ChatUserCsvAttachmentPage'),
104
+ },
99
105
  {
100
106
  slug: 'checkbox',
101
107
  title: 'Checkbox',
@@ -0,0 +1,16 @@
1
+ /** Trigger a browser download for text/binary content (no backend). */
2
+ export function downloadTextFile(
3
+ content: string,
4
+ filename: string,
5
+ mimeType: string,
6
+ ): void {
7
+ const blob = new Blob([content], { type: mimeType });
8
+ const url = URL.createObjectURL(blob);
9
+ const link = document.createElement('a');
10
+ link.href = url;
11
+ link.download = filename;
12
+ document.body.appendChild(link);
13
+ link.click();
14
+ document.body.removeChild(link);
15
+ URL.revokeObjectURL(url);
16
+ }