@sybilion/uilib 1.3.11 → 1.3.14

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 (43) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +2 -2
  2. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +7 -3
  3. package/dist/esm/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.js +32 -0
  4. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.js +16 -4
  5. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.styl.js +2 -2
  6. package/dist/esm/components/ui/Chat/ChatSheet/ChatSheet.js +4 -1
  7. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +6 -3
  8. package/dist/esm/components/ui/Chat/buildChatSendMessagePayload.js +66 -0
  9. package/dist/esm/components/ui/Chat/sanitizeAttachmentFilename.js +14 -0
  10. package/dist/esm/components/ui/Chat/userTextFileAttachments.js +6 -0
  11. package/dist/esm/contexts/chat-context.js +8 -3
  12. package/dist/esm/index.js +2 -0
  13. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +10 -6
  14. package/dist/esm/types/src/components/ui/Chat/ChatMessage/ChatMessage.d.ts +1 -1
  15. package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.d.ts +4 -0
  16. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.d.ts +1 -1
  17. package/dist/esm/types/src/components/ui/Chat/ChatSheet/ChatSheet.d.ts +1 -1
  18. package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.d.ts +10 -0
  19. package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.test.d.ts +1 -0
  20. package/dist/esm/types/src/components/ui/Chat/index.d.ts +3 -1
  21. package/dist/esm/types/src/components/ui/Chat/sanitizeAttachmentFilename.d.ts +2 -0
  22. package/dist/esm/types/src/components/ui/Chat/userTextFileAttachments.d.ts +3 -0
  23. package/dist/esm/types/src/contexts/chat-context.d.ts +3 -3
  24. package/package.json +1 -1
  25. package/src/components/ui/Chat/Chat.types.ts +10 -6
  26. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +7 -1
  27. package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +12 -5
  28. package/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.tsx +46 -0
  29. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl +7 -4
  30. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl.d.ts +1 -0
  31. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.tsx +50 -11
  32. package/src/components/ui/Chat/ChatSheet/ChatSheet.tsx +6 -0
  33. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +10 -3
  34. package/src/components/ui/Chat/buildChatSendMessagePayload.test.ts +102 -0
  35. package/src/components/ui/Chat/buildChatSendMessagePayload.ts +80 -0
  36. package/src/components/ui/Chat/index.ts +7 -1
  37. package/src/components/ui/Chat/sanitizeAttachmentFilename.ts +15 -0
  38. package/src/components/ui/Chat/userTextFileAttachments.ts +8 -0
  39. package/src/contexts/chat-context.tsx +12 -7
  40. package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +7 -5
  41. package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +0 -10
  42. package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.d.ts +0 -4
  43. package/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.tsx +0 -27
@@ -65,7 +65,7 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
65
65
  }, [isEmpty, messages.length]);
66
66
  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 }), jsxs("div", { className: S.content, children: [attachmentsDropzoneEnabled ? (jsx(DropZone, { accept: attachmentAccept, label: "Drop text files to attach", multiple: true, ghost: true, overlayScope: "container", disabled: promptBusy, className: S.attachmentDropzone, onFiles: handleAttachmentFiles })) : null, 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) => {
67
67
  const isLast = index === arr.length - 1;
68
- return (jsx(Chat.Message, { role: msg.role, text: msg.text, userCsvAttachment: msg.userCsvAttachment, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
68
+ return (jsx(Chat.Message, { role: msg.role, text: msg.text, userTextFileAttachments: msg.userTextFileAttachments, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
69
69
  ? { label: scriptContinueLabel }
70
70
  : undefined, onScriptContinue: isLast && scriptContinueLabel
71
71
  ? onScriptContinue
@@ -77,7 +77,7 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
77
77
  })
78
78
  : null, isScriptComplete &&
79
79
  onGenerateDashboard &&
80
- !generatingDashboard ? (jsxs(Button, { type: "button", variant: "default", size: "lg", disabled: isLoading, onClick: onGenerateDashboardClick, children: [jsx(ChartLineIcon, {}), "Generate Dashboard"] })) : null] })), showInlinePresets && renderPresets('inline'), isLoading && isLastMessageFromUser && (jsx(TextShimmer, { duration: 1, spread: 5, className: S.loader, children: "Thinking..." }))] }) })), jsxs("div", { className: cn(S.footer, footerClassName), children: [isEmpty ? (jsx("div", { className: S.notice, children: "Forecast Assistant can make mistakes." })) : null, jsx(Chat.Prompt, { onSubmit: handlePromptSubmitWithAttachments, disabled: promptBusy, attachments: pendingAttachments, onRemoveAttachment: handleRemoveAttachment, prefillMessage: promptPrefill ?? undefined })] })] })] })] }));
80
+ !generatingDashboard ? (jsxs(Button, { type: "button", variant: "default", size: "lg", disabled: isLoading, onClick: onGenerateDashboardClick, children: [jsx(ChartLineIcon, {}), "Generate Dashboard"] })) : null] })), showInlinePresets && renderPresets('inline'), isLoading && isLastMessageFromUser && (jsx(TextShimmer, { duration: 1, spread: 5, className: S.loader, children: "Thinking..." }))] }) })), jsxs("div", { className: cn(S.footer, footerClassName), children: [isEmpty ? (jsx("div", { className: S.notice, children: "Forecast Assistant can make mistakes." })) : null, jsx(Chat.Prompt, { onSubmit: handlePromptSubmitWithAttachments, disabled: promptBusy, attachments: pendingAttachments, onRemoveAttachment: handleRemoveAttachment, prefillMessage: promptPrefill ?? undefined, attachmentAccept: attachmentsDropzoneEnabled ? attachmentAccept : undefined, onAttachmentFiles: attachmentsDropzoneEnabled ? handleAttachmentFiles : undefined })] })] })] })] }));
81
81
  }
82
82
 
83
83
  export { ChatChrome };
@@ -3,14 +3,18 @@ import cn from 'classnames';
3
3
  import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.js';
4
4
  import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
5
5
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
6
+ import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments.js';
6
7
  import { AgentMessageContent } from './AgentMessageContent.js';
7
8
  import S from './ChatMessage.styl.js';
8
- import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble.js';
9
+ import { UserTextFileAttachmentBubble } from './UserTextFileAttachmentBubble.js';
9
10
 
10
- function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
11
+ function ChatMessage({ role, text, userTextFileAttachments, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
12
+ const fileAttachments = userTextFileAttachmentsFromMessage({
13
+ userTextFileAttachments,
14
+ });
11
15
  const isAssistant = role === MessageRole.ASSISTANT;
12
16
  const isSystem = role === MessageRole.SYSTEM;
13
- 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: jsx(InteractiveContent, { text: text }) }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
17
+ 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: jsx(InteractiveContent, { text: text }) }), fileAttachments.map(attachment => (jsx(UserTextFileAttachmentBubble, { attachment: attachment }, `${attachment.displayName}:${attachment.filename}`)))] })) }));
14
18
  }
15
19
 
16
20
  export { ChatMessage };
@@ -0,0 +1,32 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { FileChip } from '../../FileChip/FileChip.js';
3
+ import { downloadTextFile } from '../../../../utils/downloadTextFile.js';
4
+
5
+ function formatFromFilename(filename) {
6
+ const lower = filename.toLowerCase();
7
+ if (lower.endsWith('.csv'))
8
+ return 'csv';
9
+ if (lower.endsWith('.pdf'))
10
+ return 'pdf';
11
+ return 'text';
12
+ }
13
+ function mimeForFormat(format) {
14
+ if (format === 'csv')
15
+ return 'text/csv;charset=utf-8';
16
+ if (format === 'pdf')
17
+ return 'application/pdf';
18
+ return 'text/plain;charset=utf-8';
19
+ }
20
+ function hintForFormat(format) {
21
+ if (format === 'csv')
22
+ return 'Download .CSV file';
23
+ if (format === 'pdf')
24
+ return 'Download file';
25
+ return 'Download text file';
26
+ }
27
+ function UserTextFileAttachmentBubble({ attachment, }) {
28
+ const format = formatFromFilename(attachment.filename);
29
+ return (jsx(FileChip, { name: attachment.displayName, format: format, hint: hintForFormat(format), onClick: () => downloadTextFile(attachment.content, attachment.filename, mimeForFormat(format)) }));
30
+ }
31
+
32
+ export { UserTextFileAttachmentBubble };
@@ -1,17 +1,19 @@
1
- import { jsxs, jsx } from 'react/jsx-runtime';
1
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
2
  import cn from 'classnames';
3
3
  import { useState, useRef, useLayoutEffect, useEffect } from 'react';
4
4
  import useEvent from '../../../../hooks/useEvent.js';
5
- import { SendHorizontalIcon } from 'lucide-react';
5
+ import { PaperclipIcon, SendHorizontalIcon } from 'lucide-react';
6
6
  import { Button } from '../../Button/Button.js';
7
7
  import { Input } from '../../Input/Input.js';
8
8
  import { syncChatPromptTextareaHeight } from './ChatPrompt.helpers.js';
9
9
  import S from './ChatPrompt.styl.js';
10
10
  import { ChatPromptAttachments } from './ChatPromptAttachments.js';
11
11
 
12
- function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage, attachments = [], onRemoveAttachment, disabled = false, }) {
12
+ function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage, attachments = [], onRemoveAttachment, disabled = false, attachmentAccept, onAttachmentFiles, }) {
13
13
  const [message, setMessage] = useState('');
14
14
  const inputRef = useRef(null);
15
+ const fileInputRef = useRef(null);
16
+ const showAttachButton = Boolean(attachmentAccept && onAttachmentFiles);
15
17
  useLayoutEffect(() => {
16
18
  const el = inputRef.current;
17
19
  if (!el)
@@ -32,6 +34,13 @@ function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage,
32
34
  setMessage('');
33
35
  }
34
36
  };
37
+ const handleFileInputChange = (e) => {
38
+ const files = Array.from(e.target.files ?? []);
39
+ e.target.value = '';
40
+ if (files.length > 0) {
41
+ onAttachmentFiles?.(files);
42
+ }
43
+ };
35
44
  useEvent({
36
45
  event: 'keydown',
37
46
  callback: (e) => {
@@ -42,7 +51,10 @@ function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage,
42
51
  }
43
52
  },
44
53
  });
45
- return (jsxs("form", { onSubmit: handleSubmit, className: cn(S.root, className), children: [jsx(ChatPromptAttachments, { attachments: attachments, onRemove: index => onRemoveAttachment?.(index), disabled: disabled }), jsxs("div", { className: S.composer, children: [jsx(Input, { ref: inputRef, type: "textarea", rows: 1, value: message, onChange: e => setMessage(e.target.value), placeholder: placeholder || 'Type a message...', className: cn(S.input) }), jsx("div", { className: S.submitColumn, children: jsx(Button, { type: "submit", size: "sm", disabled: disabled || (!message.trim() && attachments.length === 0), children: jsx(SendHorizontalIcon, { size: 16 }) }) })] }), footer] }));
54
+ return (jsxs("form", { onSubmit: handleSubmit, className: cn(S.root, className), children: [jsx(ChatPromptAttachments, { attachments: attachments, onRemove: index => onRemoveAttachment?.(index), disabled: disabled }), jsxs("div", { className: S.composer, children: [showAttachButton ? (jsxs(Fragment, { children: [jsx("input", { ref: fileInputRef, type: "file", accept: attachmentAccept, multiple: true, className: S.fileInput, disabled: disabled, onChange: handleFileInputChange }), jsx(Button, { type: "button", variant: "ghost", icon: true, size: "sm", className: S.attachButton, "aria-label": "Attach file", disabled: disabled, onClick: e => {
55
+ e.preventDefault();
56
+ fileInputRef.current?.click();
57
+ }, children: jsx(PaperclipIcon, { size: 16 }) })] })) : null, jsx(Input, { ref: inputRef, type: "textarea", rows: 1, value: message, onChange: e => setMessage(e.target.value), placeholder: placeholder || 'Type a message...', className: cn(S.input) }), jsx("div", { className: S.submitColumn, children: jsx(Button, { type: "submit", size: "sm", disabled: disabled || (!message.trim() && attachments.length === 0), children: jsx(SendHorizontalIcon, { size: 16 }) }) })] }), footer] }));
46
58
  }
47
59
 
48
60
  export { ChatPrompt };
@@ -1,7 +1,7 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.ChatPrompt_root__5G5bq{align-items:stretch;display:flex;flex-direction:column;gap:var(--p-2);padding:var(--p-6);padding-top:var(--p-5);position:relative}.ChatPrompt_composer__H3c3N{align-items:flex-end;display:flex;flex-direction:row;gap:var(--p-3);position:relative;width:100%}.ChatPrompt_input__QPKBV{border:none;border-radius:0!important;flex:1;max-height:200px;min-height:40px;min-width:0;overflow-y:auto;padding:var(--p-2) 0 0!important;resize:none}.ChatPrompt_input__QPKBV,.ChatPrompt_input__QPKBV:focus{box-shadow:none!important}.ChatPrompt_submitColumn__0rY1R{align-items:center;display:flex;flex-direction:column;flex-shrink:0;justify-content:flex-end}.ChatPrompt_submitColumn__0rY1R>button:focus{box-shadow:0 0 0 2px var(--brand-color-500)!important}.ChatPrompt_submitColumn__0rY1R>button:first-child{border:none;position:relative;transition:all .2s}.ChatPrompt_submitColumn__0rY1R>button:first-child:focus{transform:scale(1.2)}.ChatPrompt_submitColumn__0rY1R>button:first-child:before{bottom:-100%;content:\"\";left:-100%;position:absolute;right:-100%;top:-100%}.ChatPrompt_submitColumn__0rY1R .ChatPrompt_attachButton__gi-qF{background-color:var(--page-color);box-shadow:0 0 20px var(--background)}.ChatPrompt_attachments__KG-fG{display:flex;flex-wrap:wrap;gap:var(--p-2);margin-bottom:var(--p-2)}.ChatPrompt_attachmentItem__QJk7J{flex:1 1 300px;max-width:300px;min-width:0}";
4
- var S = {"root":"ChatPrompt_root__5G5bq","composer":"ChatPrompt_composer__H3c3N","input":"ChatPrompt_input__QPKBV","submitColumn":"ChatPrompt_submitColumn__0rY1R","attachButton":"ChatPrompt_attachButton__gi-qF","attachments":"ChatPrompt_attachments__KG-fG","attachmentItem":"ChatPrompt_attachmentItem__QJk7J"};
3
+ var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.ChatPrompt_root__5G5bq{align-items:stretch;display:flex;flex-direction:column;gap:var(--p-2);padding:var(--p-6);padding-top:var(--p-5);position:relative}.ChatPrompt_composer__H3c3N{align-items:flex-end;display:flex;flex-direction:row;gap:var(--p-3);position:relative;width:100%}.ChatPrompt_fileInput__xdgPn{display:none}.ChatPrompt_attachButton__gi-qF{align-self:flex-end;flex-shrink:0}.ChatPrompt_input__QPKBV{border:none;border-radius:0!important;flex:1;max-height:200px;min-height:40px;min-width:0;overflow-y:auto;padding:var(--p-2) 0 0!important;resize:none}.ChatPrompt_input__QPKBV,.ChatPrompt_input__QPKBV:focus{box-shadow:none!important}.ChatPrompt_submitColumn__0rY1R{align-items:center;display:flex;flex-direction:column;flex-shrink:0;justify-content:flex-end}.ChatPrompt_submitColumn__0rY1R>button:focus{box-shadow:0 0 0 2px var(--brand-color-500)!important}.ChatPrompt_submitColumn__0rY1R>button:first-child{border:none;position:relative;transition:all .2s}.ChatPrompt_submitColumn__0rY1R>button:first-child:focus{transform:scale(1.2)}.ChatPrompt_submitColumn__0rY1R>button:first-child:before{bottom:-100%;content:\"\";left:-100%;position:absolute;right:-100%;top:-100%}.ChatPrompt_attachments__KG-fG{display:flex;flex-wrap:wrap;gap:var(--p-2);margin-bottom:var(--p-2)}.ChatPrompt_attachmentItem__QJk7J{flex:1 1 300px;max-width:300px;min-width:0}";
4
+ var S = {"root":"ChatPrompt_root__5G5bq","composer":"ChatPrompt_composer__H3c3N","fileInput":"ChatPrompt_fileInput__xdgPn","attachButton":"ChatPrompt_attachButton__gi-qF","input":"ChatPrompt_input__QPKBV","submitColumn":"ChatPrompt_submitColumn__0rY1R","attachments":"ChatPrompt_attachments__KG-fG","attachmentItem":"ChatPrompt_attachmentItem__QJk7J"};
5
5
  styleInject(css_248z);
6
6
 
7
7
  export { S as default };
@@ -4,7 +4,7 @@ import { Button } from '../../Button/Button.js';
4
4
  import { ChatChrome } from '../ChatChrome/ChatChrome.js';
5
5
  import { useChatPanelChromeModel } from './useChatPanelChromeModel.js';
6
6
 
7
- function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, inline = false, }) {
7
+ function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, inline = false, }) {
8
8
  const model = useChatPanelChromeModel({
9
9
  embedAsPage: inline,
10
10
  presets,
@@ -14,6 +14,9 @@ function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, r
14
14
  onGenerateDashboard,
15
15
  renderMessageChart,
16
16
  emptyState,
17
+ allowedAttachments,
18
+ allowPdfAttachments,
19
+ onAttachmentsDropped,
17
20
  });
18
21
  if (actionsRef) {
19
22
  actionsRef.current = {
@@ -2,6 +2,7 @@ import { jsx } from 'react/jsx-runtime';
2
2
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
3
3
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
4
4
  import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, parseScriptLine, textHasQuickReplyMarkers, branchKeysUsedFromChatHistory, branchKeysUsedByUserMessages, extractQuickReplyLabelKeyPairsFromText, entryBranchKeyBeforeLastAssistant, isPresetScriptGraph, branchesFromPresetScriptGraph } from '../ChatMessage/presetScript.js';
5
+ import { buildChatSendMessagePayload, displayTextFromSendPayload } from '../buildChatSendMessagePayload.js';
5
6
  import { usedPresetIdsFromMessages, formatChatTranscript } from '../chat-preset-utils.js';
6
7
  import { useChatsForScopeId, useChat, useChatOutboundPending, isChatEmpty } from '../../../../contexts/chat-context.js';
7
8
  import useEvent from '../../../../hooks/useEvent.js';
@@ -296,10 +297,11 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
296
297
  onMessage,
297
298
  onScriptComplete,
298
299
  ]);
299
- const handlePromptSubmit = useCallback(async (message) => {
300
+ const handlePromptSubmit = useCallback(async (message, attachments) => {
300
301
  const chatId = currentChatId;
301
302
  if (!chatId)
302
303
  return;
304
+ const stagedAttachments = attachments ?? [];
303
305
  const quickBranches = quickReplyBranchesByChat[chatId];
304
306
  const graphActive = Boolean(quickBranches && Object.keys(quickBranches).length > 0);
305
307
  const intake = intakeByChatId[chatId];
@@ -402,8 +404,9 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
402
404
  try {
403
405
  if (chatId)
404
406
  endLocalDemoFlow(chatId);
405
- await sendMessage(message);
406
- onMessage?.(message);
407
+ const payload = buildChatSendMessagePayload(message, stagedAttachments);
408
+ await sendMessage(payload);
409
+ onMessage?.(displayTextFromSendPayload(payload));
407
410
  }
408
411
  catch (error) {
409
412
  logger.error('Error sending chat message:', error);
@@ -0,0 +1,66 @@
1
+ import { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename.js';
2
+
3
+ function defaultExtForAttachment(item) {
4
+ const name = item.file.name.toLowerCase();
5
+ if (item.kind === 'pdf' || name.endsWith('.pdf'))
6
+ return 'pdf';
7
+ if (name.endsWith('.csv'))
8
+ return 'csv';
9
+ if (name.endsWith('.json'))
10
+ return 'json';
11
+ if (name.endsWith('.md') || name.endsWith('.markdown'))
12
+ return 'md';
13
+ if (name.endsWith('.html') || name.endsWith('.htm'))
14
+ return 'html';
15
+ if (name.endsWith('.xml'))
16
+ return 'xml';
17
+ if (name.endsWith('.yaml') || name.endsWith('.yml'))
18
+ return 'yaml';
19
+ if (name.endsWith('.tsv'))
20
+ return 'tsv';
21
+ if (name.endsWith('.ics'))
22
+ return 'ics';
23
+ return 'txt';
24
+ }
25
+ function dropItemToUserAttachment(item) {
26
+ const ext = defaultExtForAttachment(item);
27
+ return {
28
+ displayName: item.file.name,
29
+ filename: sanitizeAttachmentFilename(item.file.name, ext),
30
+ content: item.text,
31
+ };
32
+ }
33
+ function buildApiMessage(displayText, attachments) {
34
+ const parts = [
35
+ displayText,
36
+ ...attachments.map(item => item.text.trim()).filter(Boolean),
37
+ ].filter(Boolean);
38
+ return parts.join('\n\n');
39
+ }
40
+ /** Resolve file attachments on a send payload. */
41
+ function normalizeUserTextFileAttachments(payload) {
42
+ return payload.userTextFileAttachments ?? [];
43
+ }
44
+ /**
45
+ * Build `sendMessage` input from composer text and staged drop items.
46
+ * Returns a plain string when there are no attachments.
47
+ */
48
+ function buildChatSendMessagePayload(displayText, attachments) {
49
+ const trimmed = displayText.trim();
50
+ if (attachments.length === 0) {
51
+ return trimmed;
52
+ }
53
+ const userTextFileAttachments = attachments.map(dropItemToUserAttachment);
54
+ const resolvedDisplayText = trimmed.length > 0 ? trimmed : userTextFileAttachments[0].displayName;
55
+ return {
56
+ apiMessage: buildApiMessage(resolvedDisplayText, attachments),
57
+ displayText: resolvedDisplayText,
58
+ userTextFileAttachments,
59
+ };
60
+ }
61
+ /** Display text from a string or structured send payload. */
62
+ function displayTextFromSendPayload(message) {
63
+ return typeof message === 'string' ? message : message.displayText;
64
+ }
65
+
66
+ export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments };
@@ -0,0 +1,14 @@
1
+ /** Safe download filename from a display title or original file name. */
2
+ function sanitizeAttachmentFilename(displayTitle, defaultExt = 'txt') {
3
+ const cleaned = displayTitle
4
+ .replace(/[/\\?%*:|"<>]/g, '-')
5
+ .replace(/\s+/g, ' ')
6
+ .trim()
7
+ .slice(0, 120);
8
+ const base = cleaned.length > 0 ? cleaned : 'attachment';
9
+ const ext = defaultExt.startsWith('.') ? defaultExt : `.${defaultExt}`;
10
+ const pattern = new RegExp(`${ext.replace('.', '\\.')}$`, 'i');
11
+ return pattern.test(base) ? base : `${base}${ext}`;
12
+ }
13
+
14
+ export { sanitizeAttachmentFilename };
@@ -0,0 +1,6 @@
1
+ /** File attachments on a stored message. */
2
+ function userTextFileAttachmentsFromMessage(message) {
3
+ return message.userTextFileAttachments ?? [];
4
+ }
5
+
6
+ export { userTextFileAttachmentsFromMessage };
@@ -1,6 +1,7 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { createContext, useState, useCallback, useEffect, useContext, useMemo } from 'react';
3
3
  import { MessageRole } from '../components/ui/Chat/Chat.types.js';
4
+ import { normalizeUserTextFileAttachments } from '../components/ui/Chat/buildChatSendMessagePayload.js';
4
5
  import { stripJsonDashboardFences } from '../lib/dashboard-spec/stripJsonDashboardFences.js';
5
6
  import { LS } from '@homecode/ui';
6
7
 
@@ -181,13 +182,17 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
181
182
  return undefined;
182
183
  addScopeIdToRegistry(scopeId);
183
184
  const storedText = stripJsonDashboardFences(text);
184
- const attachment = role === MessageRole.USER ? options?.userCsvAttachment : undefined;
185
+ const attachments = role === MessageRole.USER
186
+ ? options?.userTextFileAttachments
187
+ : undefined;
185
188
  const newMessage = {
186
189
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
187
190
  role,
188
191
  text: storedText,
189
192
  timestamp: Date.now(),
190
- ...(attachment ? { userCsvAttachment: attachment } : {}),
193
+ ...(attachments?.length
194
+ ? { userTextFileAttachments: attachments }
195
+ : {}),
191
196
  };
192
197
  setChats(prev => {
193
198
  const scopeChats = prev[scopeId] ?? [];
@@ -232,7 +237,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
232
237
  }
233
238
  else {
234
239
  addMessage(scopeId, targetChatId, MessageRole.USER, message.displayText, {
235
- userCsvAttachment: message.userCsvAttachment,
240
+ userTextFileAttachments: normalizeUserTextFileAttachments(message),
236
241
  });
237
242
  }
238
243
  const pendingChatSessionId = targetChatId;
package/dist/esm/index.js CHANGED
@@ -23,6 +23,8 @@ export { Chat } from './components/ui/Chat/Chat.js';
23
23
  export { usedPresetIdsFromMessages } from './components/ui/Chat/chat-preset-utils.js';
24
24
  export { ChatChrome } from './components/ui/Chat/ChatChrome/ChatChrome.js';
25
25
  export { TEXT_ATTACHMENT_ACCEPT_PARTS, filterToTextAttachments } from './components/ui/Chat/chatAttachmentAccept.js';
26
+ export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments } from './components/ui/Chat/buildChatSendMessagePayload.js';
27
+ export { sanitizeAttachmentFilename } from './components/ui/Chat/sanitizeAttachmentFilename.js';
26
28
  export { ChatSheet } from './components/ui/Chat/ChatSheet/ChatSheet.js';
27
29
  export { useChatPanelChromeModel } from './components/ui/Chat/ChatSheet/useChatPanelChromeModel.js';
28
30
  export { ChatMessage } from './components/ui/Chat/ChatMessage/ChatMessage.js';
@@ -7,24 +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 = {
10
+ /** USER-only: text file attached to a message; shown as downloadable file row(s). */
11
+ export type UserTextFileAttachment = {
12
12
  displayName: string;
13
13
  filename: string;
14
14
  content: string;
15
15
  };
16
- /** Send full text to the chat API while showing `displayText` + optional CSV attachment in the UI. */
16
+ /** Send full text to the chat API while showing `displayText` + optional file attachment(s) in the UI. */
17
17
  export type ChatSendMessagePayload = {
18
18
  apiMessage: string;
19
19
  displayText: string;
20
- userCsvAttachment?: UserCsvAttachment;
20
+ userTextFileAttachments?: UserTextFileAttachment[];
21
21
  };
22
22
  export interface Message {
23
23
  id: string;
24
24
  role: MessageRole;
25
25
  text: string;
26
26
  timestamp: number;
27
- userCsvAttachment?: UserCsvAttachment;
27
+ userTextFileAttachments?: UserTextFileAttachment[];
28
28
  }
29
29
  export interface Chat {
30
30
  session_id: string;
@@ -77,11 +77,15 @@ export interface ChatPromptProps {
77
77
  /** Staged files shown above the composer until send. */
78
78
  attachments?: ChatAttachmentDropItem[];
79
79
  onRemoveAttachment?: (index: number) => void;
80
+ /** HTML `accept` for the attach file picker; set with `onAttachmentFiles`. */
81
+ attachmentAccept?: string;
82
+ /** Called when the user picks files via the attach button. */
83
+ onAttachmentFiles?: (files: File[]) => void;
80
84
  }
81
85
  export interface ChatMessageProps {
82
86
  role: MessageRole;
83
87
  text: string;
84
- userCsvAttachment?: UserCsvAttachment;
88
+ userTextFileAttachments?: UserTextFileAttachment[];
85
89
  onQuickReply?: (branchKey: string, displayLabel: string) => void;
86
90
  /** Branch keys already taken (e.g. from chat history); hide quick-reply buttons for these. */
87
91
  suppressedQuickReplyKeys?: ReadonlySet<string>;
@@ -1,2 +1,2 @@
1
1
  import { type ChatMessageProps } from '../Chat.types';
2
- export declare function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatMessage({ role, text, userTextFileAttachments, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import type { UserTextFileAttachment } from '../Chat.types';
2
+ export declare function UserTextFileAttachmentBubble({ attachment, }: {
3
+ attachment: UserTextFileAttachment;
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -1,2 +1,2 @@
1
1
  import type { ChatPromptProps } from '../Chat.types';
2
- export declare function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage, attachments, onRemoveAttachment, disabled, }: ChatPromptProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage, attachments, onRemoveAttachment, disabled, attachmentAccept, onAttachmentFiles, }: ChatPromptProps): import("react/jsx-runtime").JSX.Element;
@@ -19,4 +19,4 @@ export interface ChatSheetProps extends Omit<UseChatPanelChromeModelInput, 'embe
19
19
  */
20
20
  inline?: boolean;
21
21
  }
22
- export declare function ChatSheet({ triggerLabel, triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, inline, }: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
22
+ export declare function ChatSheet({ triggerLabel, triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, inline, }: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import type { ChatAttachmentDropItem, ChatSendMessagePayload, UserTextFileAttachment } from './Chat.types';
2
+ /** Resolve file attachments on a send payload. */
3
+ export declare function normalizeUserTextFileAttachments(payload: ChatSendMessagePayload): UserTextFileAttachment[];
4
+ /**
5
+ * Build `sendMessage` input from composer text and staged drop items.
6
+ * Returns a plain string when there are no attachments.
7
+ */
8
+ export declare function buildChatSendMessagePayload(displayText: string, attachments: readonly ChatAttachmentDropItem[]): string | ChatSendMessagePayload;
9
+ /** Display text from a string or structured send payload. */
10
+ export declare function displayTextFromSendPayload(message: string | ChatSendMessagePayload): string;
@@ -3,6 +3,8 @@ export { usedPresetIdsFromMessages } from './chat-preset-utils';
3
3
  export { ChatChrome } from './ChatChrome';
4
4
  export type { ChatChromeProps, ChatChromeResizeHandleConfig, } from './ChatChrome';
5
5
  export { TEXT_ATTACHMENT_ACCEPT_PARTS, filterToTextAttachments, } from './chatAttachmentAccept';
6
+ export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments, } from './buildChatSendMessagePayload';
7
+ export { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
6
8
  export { ChatSheet } from './ChatSheet/ChatSheet';
7
9
  export { useChatPanelChromeModel } from './ChatSheet/useChatPanelChromeModel';
8
10
  export type { ChatSheetActions, ChatSheetProps } from './ChatSheet/ChatSheet';
@@ -10,6 +12,6 @@ export type { UseChatPanelChromeModelInput, UseChatPanelChromeModelResult, } fro
10
12
  export { ChatMessage } from './ChatMessage';
11
13
  export { ChatPrompt } from './ChatPrompt';
12
14
  export { ChatPresets } from './ChatPresets';
13
- export type { Chat as ChatType, ChatAttachmentDropItem, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserCsvAttachment, } from './Chat.types';
15
+ export type { Chat as ChatType, ChatAttachmentDropItem, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserTextFileAttachment, } from './Chat.types';
14
16
  export { MessageRole } from './Chat.types';
15
17
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -0,0 +1,2 @@
1
+ /** Safe download filename from a display title or original file name. */
2
+ export declare function sanitizeAttachmentFilename(displayTitle: string, defaultExt?: string): string;
@@ -0,0 +1,3 @@
1
+ import type { Message, UserTextFileAttachment } from './Chat.types';
2
+ /** File attachments on a stored message. */
3
+ export declare function userTextFileAttachmentsFromMessage(message: Pick<Message, 'userTextFileAttachments'>): UserTextFileAttachment[];
@@ -1,10 +1,10 @@
1
1
  import { ReactNode } from 'react';
2
- import { type Chat, type ChatSendMessagePayload, MessageRole, type UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
2
+ import { type Chat, type ChatSendMessagePayload, MessageRole, type UserTextFileAttachment } 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';
5
+ export type { ChatSendMessagePayload, UserTextFileAttachment, } from '#uilib/components/ui/Chat/Chat.types';
6
6
  export type AddChatMessageOptions = {
7
- userCsvAttachment?: UserCsvAttachment;
7
+ userTextFileAttachments?: UserTextFileAttachment[];
8
8
  };
9
9
  export interface ChatContextType {
10
10
  /** Returns the new session id, or undefined if no user / not created. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.11",
3
+ "version": "1.3.14",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -11,18 +11,18 @@ 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 = {
14
+ /** USER-only: text file attached to a message; shown as downloadable file row(s). */
15
+ export type UserTextFileAttachment = {
16
16
  displayName: string;
17
17
  filename: string;
18
18
  content: string;
19
19
  };
20
20
 
21
- /** Send full text to the chat API while showing `displayText` + optional CSV attachment in the UI. */
21
+ /** Send full text to the chat API while showing `displayText` + optional file attachment(s) in the UI. */
22
22
  export type ChatSendMessagePayload = {
23
23
  apiMessage: string;
24
24
  displayText: string;
25
- userCsvAttachment?: UserCsvAttachment;
25
+ userTextFileAttachments?: UserTextFileAttachment[];
26
26
  };
27
27
 
28
28
  export interface Message {
@@ -30,7 +30,7 @@ export interface Message {
30
30
  role: MessageRole;
31
31
  text: string;
32
32
  timestamp: number;
33
- userCsvAttachment?: UserCsvAttachment;
33
+ userTextFileAttachments?: UserTextFileAttachment[];
34
34
  }
35
35
 
36
36
  export interface Chat {
@@ -88,12 +88,16 @@ export interface ChatPromptProps {
88
88
  /** Staged files shown above the composer until send. */
89
89
  attachments?: ChatAttachmentDropItem[];
90
90
  onRemoveAttachment?: (index: number) => void;
91
+ /** HTML `accept` for the attach file picker; set with `onAttachmentFiles`. */
92
+ attachmentAccept?: string;
93
+ /** Called when the user picks files via the attach button. */
94
+ onAttachmentFiles?: (files: File[]) => void;
91
95
  }
92
96
 
93
97
  export interface ChatMessageProps {
94
98
  role: MessageRole;
95
99
  text: string;
96
- userCsvAttachment?: UserCsvAttachment;
100
+ userTextFileAttachments?: UserTextFileAttachment[];
97
101
  onQuickReply?: (branchKey: string, displayLabel: string) => void;
98
102
  /** Branch keys already taken (e.g. from chat history); hide quick-reply buttons for these. */
99
103
  suppressedQuickReplyKeys?: ReadonlySet<string>;
@@ -198,7 +198,7 @@ export function ChatChrome({
198
198
  key={msg.id}
199
199
  role={msg.role}
200
200
  text={msg.text}
201
- userCsvAttachment={msg.userCsvAttachment}
201
+ userTextFileAttachments={msg.userTextFileAttachments}
202
202
  onQuickReply={onQuickReply}
203
203
  suppressedQuickReplyKeys={suppressedQuickReplyKeys}
204
204
  quickReplyDisabled={isLoading}
@@ -284,6 +284,12 @@ export function ChatChrome({
284
284
  attachments={pendingAttachments}
285
285
  onRemoveAttachment={handleRemoveAttachment}
286
286
  prefillMessage={promptPrefill ?? undefined}
287
+ attachmentAccept={
288
+ attachmentsDropzoneEnabled ? attachmentAccept : undefined
289
+ }
290
+ onAttachmentFiles={
291
+ attachmentsDropzoneEnabled ? handleAttachmentFiles : undefined
292
+ }
287
293
  />
288
294
  </div>
289
295
  </Chat>
@@ -8,14 +8,15 @@ import {
8
8
  GENERATING_DASHBOARD_SYSTEM_TEXT,
9
9
  MessageRole,
10
10
  } from '../Chat.types';
11
+ import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments';
11
12
  import { AgentMessageContent } from './AgentMessageContent';
12
13
  import S from './ChatMessage.styl';
13
- import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble';
14
+ import { UserTextFileAttachmentBubble } from './UserTextFileAttachmentBubble';
14
15
 
15
16
  export function ChatMessage({
16
17
  role,
17
18
  text,
18
- userCsvAttachment,
19
+ userTextFileAttachments,
19
20
  onQuickReply,
20
21
  suppressedQuickReplyKeys,
21
22
  quickReplyDisabled,
@@ -24,6 +25,9 @@ export function ChatMessage({
24
25
  onScriptContinue,
25
26
  renderMessageChart,
26
27
  }: ChatMessageProps) {
28
+ const fileAttachments = userTextFileAttachmentsFromMessage({
29
+ userTextFileAttachments,
30
+ });
27
31
  const isAssistant = role === MessageRole.ASSISTANT;
28
32
  const isSystem = role === MessageRole.SYSTEM;
29
33
 
@@ -53,9 +57,12 @@ export function ChatMessage({
53
57
  <div className={S.text}>
54
58
  <InteractiveContent text={text} />
55
59
  </div>
56
- {userCsvAttachment ? (
57
- <UserCsvAttachmentBubble attachment={userCsvAttachment} />
58
- ) : null}
60
+ {fileAttachments.map(attachment => (
61
+ <UserTextFileAttachmentBubble
62
+ key={`${attachment.displayName}:${attachment.filename}`}
63
+ attachment={attachment}
64
+ />
65
+ ))}
59
66
  </div>
60
67
  )}
61
68
  </div>
@@ -0,0 +1,46 @@
1
+ import { FileChip, type FileChipFormat } from '#uilib/components/ui/FileChip';
2
+ import { downloadTextFile } from '#uilib/utils/downloadTextFile';
3
+
4
+ import type { UserTextFileAttachment } from '../Chat.types';
5
+
6
+ function formatFromFilename(filename: string): FileChipFormat {
7
+ const lower = filename.toLowerCase();
8
+ if (lower.endsWith('.csv')) return 'csv';
9
+ if (lower.endsWith('.pdf')) return 'pdf';
10
+ return 'text';
11
+ }
12
+
13
+ function mimeForFormat(format: FileChipFormat): string {
14
+ if (format === 'csv') return 'text/csv;charset=utf-8';
15
+ if (format === 'pdf') return 'application/pdf';
16
+ return 'text/plain;charset=utf-8';
17
+ }
18
+
19
+ function hintForFormat(format: FileChipFormat): string {
20
+ if (format === 'csv') return 'Download .CSV file';
21
+ if (format === 'pdf') return 'Download file';
22
+ return 'Download text file';
23
+ }
24
+
25
+ export function UserTextFileAttachmentBubble({
26
+ attachment,
27
+ }: {
28
+ attachment: UserTextFileAttachment;
29
+ }) {
30
+ const format = formatFromFilename(attachment.filename);
31
+
32
+ return (
33
+ <FileChip
34
+ name={attachment.displayName}
35
+ format={format}
36
+ hint={hintForFormat(format)}
37
+ onClick={() =>
38
+ downloadTextFile(
39
+ attachment.content,
40
+ attachment.filename,
41
+ mimeForFormat(format),
42
+ )
43
+ }
44
+ />
45
+ );
46
+ }
@@ -19,6 +19,13 @@ INPUT_MAX_HEIGHT = 200px
19
19
  gap var(--p-3)
20
20
  width 100%
21
21
 
22
+ .fileInput
23
+ display none
24
+
25
+ .attachButton
26
+ flex-shrink 0
27
+ align-self flex-end
28
+
22
29
  .input
23
30
  flex 1
24
31
  min-width 0
@@ -61,10 +68,6 @@ INPUT_MAX_HEIGHT = 200px
61
68
  right -100%
62
69
  bottom -100%
63
70
 
64
- .attachButton
65
- background-color var(--page-color)
66
- box-shadow 0 0 20px var(--background)
67
-
68
71
  .attachments
69
72
  display flex
70
73
  flex-wrap wrap
@@ -5,6 +5,7 @@ interface CssExports {
5
5
  'attachmentItem': string;
6
6
  'attachments': string;
7
7
  'composer': string;
8
+ 'fileInput': string;
8
9
  'input': string;
9
10
  'root': string;
10
11
  'submitColumn': string;
@@ -1,8 +1,15 @@
1
1
  import cn from 'classnames';
2
- import { FormEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import {
3
+ ChangeEvent,
4
+ FormEvent,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
3
10
 
4
11
  import useEvent from '#uilib/hooks/useEvent';
5
- import { SendHorizontalIcon } from 'lucide-react';
12
+ import { PaperclipIcon, SendHorizontalIcon } from 'lucide-react';
6
13
 
7
14
  import { Button } from '../../Button';
8
15
  import { Input } from '../../Input';
@@ -20,9 +27,13 @@ export function ChatPrompt({
20
27
  attachments = [],
21
28
  onRemoveAttachment,
22
29
  disabled = false,
30
+ attachmentAccept,
31
+ onAttachmentFiles,
23
32
  }: ChatPromptProps) {
24
33
  const [message, setMessage] = useState('');
25
34
  const inputRef = useRef<HTMLTextAreaElement>(null);
35
+ const fileInputRef = useRef<HTMLInputElement>(null);
36
+ const showAttachButton = Boolean(attachmentAccept && onAttachmentFiles);
26
37
 
27
38
  useLayoutEffect(() => {
28
39
  const el = inputRef.current;
@@ -47,6 +58,14 @@ export function ChatPrompt({
47
58
  }
48
59
  };
49
60
 
61
+ const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
62
+ const files = Array.from(e.target.files ?? []);
63
+ e.target.value = '';
64
+ if (files.length > 0) {
65
+ onAttachmentFiles?.(files);
66
+ }
67
+ };
68
+
50
69
  useEvent({
51
70
  event: 'keydown',
52
71
  callback: (e: KeyboardEvent) => {
@@ -66,6 +85,35 @@ export function ChatPrompt({
66
85
  disabled={disabled}
67
86
  />
68
87
  <div className={S.composer}>
88
+ {showAttachButton ? (
89
+ <>
90
+ <input
91
+ ref={fileInputRef}
92
+ type="file"
93
+ accept={attachmentAccept}
94
+ multiple
95
+ className={S.fileInput}
96
+ disabled={disabled}
97
+ onChange={handleFileInputChange}
98
+ />
99
+ <Button
100
+ type="button"
101
+ variant="ghost"
102
+ icon
103
+ size="sm"
104
+ className={S.attachButton}
105
+ aria-label="Attach file"
106
+ disabled={disabled}
107
+ onClick={e => {
108
+ e.preventDefault();
109
+ fileInputRef.current?.click();
110
+ }}
111
+ >
112
+ <PaperclipIcon size={16} />
113
+ </Button>
114
+ </>
115
+ ) : null}
116
+
69
117
  <Input
70
118
  ref={inputRef}
71
119
  type="textarea"
@@ -85,15 +133,6 @@ export function ChatPrompt({
85
133
  <SendHorizontalIcon size={16} />
86
134
  </Button>
87
135
  </div>
88
-
89
- {/* <Button
90
- variant="outline"
91
- size="sm"
92
- onClick={e => e.preventDefault()}
93
- className={S.attachButton}
94
- >
95
- <PaperclipIcon size={16} />
96
- </Button> */}
97
136
  </div>
98
137
 
99
138
  {footer}
@@ -44,6 +44,9 @@ export function ChatSheet({
44
44
  onGenerateDashboard,
45
45
  renderMessageChart,
46
46
  emptyState,
47
+ allowedAttachments,
48
+ allowPdfAttachments,
49
+ onAttachmentsDropped,
47
50
  inline = false,
48
51
  }: ChatSheetProps) {
49
52
  const model = useChatPanelChromeModel({
@@ -55,6 +58,9 @@ export function ChatSheet({
55
58
  onGenerateDashboard,
56
59
  renderMessageChart,
57
60
  emptyState,
61
+ allowedAttachments,
62
+ allowPdfAttachments,
63
+ onAttachmentsDropped,
58
64
  });
59
65
 
60
66
  if (actionsRef) {
@@ -19,6 +19,10 @@ import {
19
19
  textHasQuickReplyMarkers,
20
20
  } from '#uilib/components/ui/Chat/ChatMessage/presetScript';
21
21
  import type { ChatPresetsLayout } from '#uilib/components/ui/Chat/ChatPresets';
22
+ import {
23
+ buildChatSendMessagePayload,
24
+ displayTextFromSendPayload,
25
+ } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
22
26
  import {
23
27
  formatChatTranscript,
24
28
  usedPresetIdsFromMessages,
@@ -462,10 +466,12 @@ export function useChatPanelChromeModel({
462
466
  );
463
467
 
464
468
  const handlePromptSubmit = useCallback(
465
- async (message: string) => {
469
+ async (message: string, attachments?: ChatAttachmentDropItem[]) => {
466
470
  const chatId = currentChatId;
467
471
  if (!chatId) return;
468
472
 
473
+ const stagedAttachments = attachments ?? [];
474
+
469
475
  const quickBranches = quickReplyBranchesByChat[chatId];
470
476
  const graphActive = Boolean(
471
477
  quickBranches && Object.keys(quickBranches).length > 0,
@@ -585,8 +591,9 @@ export function useChatPanelChromeModel({
585
591
 
586
592
  try {
587
593
  if (chatId) endLocalDemoFlow(chatId);
588
- await sendMessage(message);
589
- onMessage?.(message);
594
+ const payload = buildChatSendMessagePayload(message, stagedAttachments);
595
+ await sendMessage(payload);
596
+ onMessage?.(displayTextFromSendPayload(payload));
590
597
  } catch (error) {
591
598
  logger.error('Error sending chat message:', error);
592
599
  }
@@ -0,0 +1,102 @@
1
+ import type { ChatAttachmentDropItem } from './Chat.types';
2
+ import {
3
+ buildChatSendMessagePayload,
4
+ displayTextFromSendPayload,
5
+ normalizeUserTextFileAttachments,
6
+ } from './buildChatSendMessagePayload';
7
+
8
+ function makeDropItem(
9
+ name: string,
10
+ text: string,
11
+ kind: 'text' | 'pdf' = 'text',
12
+ ): ChatAttachmentDropItem {
13
+ return {
14
+ file: { name } as File,
15
+ text,
16
+ kind,
17
+ };
18
+ }
19
+
20
+ describe('buildChatSendMessagePayload', () => {
21
+ it('returns trimmed string when there are no attachments', () => {
22
+ expect(buildChatSendMessagePayload(' hello ', [])).toBe('hello');
23
+ });
24
+
25
+ it('builds payload with user text and full file bodies in apiMessage', () => {
26
+ const result = buildChatSendMessagePayload('Question', [
27
+ makeDropItem('data.csv', 'a,b\n1,2'),
28
+ ]);
29
+ expect(typeof result).toBe('object');
30
+ if (typeof result === 'string') {
31
+ throw new Error('expected payload object');
32
+ }
33
+ expect(result.displayText).toBe('Question');
34
+ expect(result.apiMessage).toBe('Question\n\na,b\n1,2');
35
+ expect(result.userTextFileAttachments).toHaveLength(1);
36
+ expect(result.userTextFileAttachments?.[0].displayName).toBe('data.csv');
37
+ expect(result.userTextFileAttachments?.[0].content).toBe('a,b\n1,2');
38
+ expect(result.userTextFileAttachments?.[0].filename).toMatch(/\.csv$/i);
39
+ });
40
+
41
+ it('uses first file name as displayText when message is empty', () => {
42
+ const result = buildChatSendMessagePayload('', [
43
+ makeDropItem('report.pdf', 'pdf text', 'pdf'),
44
+ ]);
45
+ if (typeof result === 'string') {
46
+ throw new Error('expected payload object');
47
+ }
48
+ expect(result.displayText).toBe('report.pdf');
49
+ expect(result.apiMessage).toBe('report.pdf\n\npdf text');
50
+ expect(result.userTextFileAttachments?.[0].filename).toMatch(/\.pdf$/i);
51
+ });
52
+
53
+ it('maps multiple attachments', () => {
54
+ const result = buildChatSendMessagePayload('Hi', [
55
+ makeDropItem('one.txt', 'first'),
56
+ makeDropItem('two.txt', 'second'),
57
+ ]);
58
+ if (typeof result === 'string') {
59
+ throw new Error('expected payload object');
60
+ }
61
+ expect(result.userTextFileAttachments).toHaveLength(2);
62
+ expect(result.apiMessage).toBe('Hi\n\nfirst\n\nsecond');
63
+ });
64
+ });
65
+
66
+ describe('normalizeUserTextFileAttachments', () => {
67
+ it('returns userTextFileAttachments or empty array', () => {
68
+ const attachment = {
69
+ displayName: 'data.csv',
70
+ filename: 'data.csv',
71
+ content: 'x',
72
+ };
73
+ expect(
74
+ normalizeUserTextFileAttachments({
75
+ apiMessage: 'a',
76
+ displayText: 'b',
77
+ userTextFileAttachments: [attachment],
78
+ }),
79
+ ).toEqual([attachment]);
80
+ expect(
81
+ normalizeUserTextFileAttachments({
82
+ apiMessage: 'a',
83
+ displayText: 'b',
84
+ }),
85
+ ).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe('displayTextFromSendPayload', () => {
90
+ it('returns string as-is', () => {
91
+ expect(displayTextFromSendPayload('plain')).toBe('plain');
92
+ });
93
+
94
+ it('returns displayText from payload', () => {
95
+ expect(
96
+ displayTextFromSendPayload({
97
+ apiMessage: 'full',
98
+ displayText: 'short',
99
+ }),
100
+ ).toBe('short');
101
+ });
102
+ });
@@ -0,0 +1,80 @@
1
+ import type {
2
+ ChatAttachmentDropItem,
3
+ ChatSendMessagePayload,
4
+ UserTextFileAttachment,
5
+ } from './Chat.types';
6
+ import { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
7
+
8
+ function defaultExtForAttachment(item: ChatAttachmentDropItem): string {
9
+ const name = item.file.name.toLowerCase();
10
+ if (item.kind === 'pdf' || name.endsWith('.pdf')) return 'pdf';
11
+ if (name.endsWith('.csv')) return 'csv';
12
+ if (name.endsWith('.json')) return 'json';
13
+ if (name.endsWith('.md') || name.endsWith('.markdown')) return 'md';
14
+ if (name.endsWith('.html') || name.endsWith('.htm')) return 'html';
15
+ if (name.endsWith('.xml')) return 'xml';
16
+ if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml';
17
+ if (name.endsWith('.tsv')) return 'tsv';
18
+ if (name.endsWith('.ics')) return 'ics';
19
+ return 'txt';
20
+ }
21
+
22
+ function dropItemToUserAttachment(
23
+ item: ChatAttachmentDropItem,
24
+ ): UserTextFileAttachment {
25
+ const ext = defaultExtForAttachment(item);
26
+ return {
27
+ displayName: item.file.name,
28
+ filename: sanitizeAttachmentFilename(item.file.name, ext),
29
+ content: item.text,
30
+ };
31
+ }
32
+
33
+ function buildApiMessage(
34
+ displayText: string,
35
+ attachments: readonly ChatAttachmentDropItem[],
36
+ ): string {
37
+ const parts = [
38
+ displayText,
39
+ ...attachments.map(item => item.text.trim()).filter(Boolean),
40
+ ].filter(Boolean);
41
+ return parts.join('\n\n');
42
+ }
43
+
44
+ /** Resolve file attachments on a send payload. */
45
+ export function normalizeUserTextFileAttachments(
46
+ payload: ChatSendMessagePayload,
47
+ ): UserTextFileAttachment[] {
48
+ return payload.userTextFileAttachments ?? [];
49
+ }
50
+
51
+ /**
52
+ * Build `sendMessage` input from composer text and staged drop items.
53
+ * Returns a plain string when there are no attachments.
54
+ */
55
+ export function buildChatSendMessagePayload(
56
+ displayText: string,
57
+ attachments: readonly ChatAttachmentDropItem[],
58
+ ): string | ChatSendMessagePayload {
59
+ const trimmed = displayText.trim();
60
+ if (attachments.length === 0) {
61
+ return trimmed;
62
+ }
63
+
64
+ const userTextFileAttachments = attachments.map(dropItemToUserAttachment);
65
+ const resolvedDisplayText =
66
+ trimmed.length > 0 ? trimmed : userTextFileAttachments[0].displayName;
67
+
68
+ return {
69
+ apiMessage: buildApiMessage(resolvedDisplayText, attachments),
70
+ displayText: resolvedDisplayText,
71
+ userTextFileAttachments,
72
+ };
73
+ }
74
+
75
+ /** Display text from a string or structured send payload. */
76
+ export function displayTextFromSendPayload(
77
+ message: string | ChatSendMessagePayload,
78
+ ): string {
79
+ return typeof message === 'string' ? message : message.displayText;
80
+ }
@@ -9,6 +9,12 @@ export {
9
9
  TEXT_ATTACHMENT_ACCEPT_PARTS,
10
10
  filterToTextAttachments,
11
11
  } from './chatAttachmentAccept';
12
+ export {
13
+ buildChatSendMessagePayload,
14
+ displayTextFromSendPayload,
15
+ normalizeUserTextFileAttachments,
16
+ } from './buildChatSendMessagePayload';
17
+ export { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
12
18
  export { ChatSheet } from './ChatSheet/ChatSheet';
13
19
  export { useChatPanelChromeModel } from './ChatSheet/useChatPanelChromeModel';
14
20
  export type { ChatSheetActions, ChatSheetProps } from './ChatSheet/ChatSheet';
@@ -26,7 +32,7 @@ export type {
26
32
  ChatProps,
27
33
  ChatPreset as ChatPresetType,
28
34
  Message,
29
- UserCsvAttachment,
35
+ UserTextFileAttachment,
30
36
  } from './Chat.types';
31
37
  export { MessageRole } from './Chat.types';
32
38
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -0,0 +1,15 @@
1
+ /** Safe download filename from a display title or original file name. */
2
+ export function sanitizeAttachmentFilename(
3
+ displayTitle: string,
4
+ defaultExt = 'txt',
5
+ ): string {
6
+ const cleaned = displayTitle
7
+ .replace(/[/\\?%*:|"<>]/g, '-')
8
+ .replace(/\s+/g, ' ')
9
+ .trim()
10
+ .slice(0, 120);
11
+ const base = cleaned.length > 0 ? cleaned : 'attachment';
12
+ const ext = defaultExt.startsWith('.') ? defaultExt : `.${defaultExt}`;
13
+ const pattern = new RegExp(`${ext.replace('.', '\\.')}$`, 'i');
14
+ return pattern.test(base) ? base : `${base}${ext}`;
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { Message, UserTextFileAttachment } from './Chat.types';
2
+
3
+ /** File attachments on a stored message. */
4
+ export function userTextFileAttachmentsFromMessage(
5
+ message: Pick<Message, 'userTextFileAttachments'>,
6
+ ): UserTextFileAttachment[] {
7
+ return message.userTextFileAttachments ?? [];
8
+ }
@@ -13,8 +13,9 @@ import {
13
13
  type ChatSendMessagePayload,
14
14
  type Message,
15
15
  MessageRole,
16
- type UserCsvAttachment,
16
+ type UserTextFileAttachment,
17
17
  } from '#uilib/components/ui/Chat/Chat.types';
18
+ import { normalizeUserTextFileAttachments } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
18
19
  import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
19
20
  import type { ChatResponse } from '#uilib/types/chat-api.types';
20
21
  import { LS } from '@homecode/ui';
@@ -26,14 +27,14 @@ export type SendChatMessageFn = (
26
27
 
27
28
  export type {
28
29
  ChatSendMessagePayload,
29
- UserCsvAttachment,
30
+ UserTextFileAttachment,
30
31
  } from '#uilib/components/ui/Chat/Chat.types';
31
32
 
32
33
  const CHATS_PREFIX = 'chats-';
33
34
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
34
35
 
35
36
  export type AddChatMessageOptions = {
36
- userCsvAttachment?: UserCsvAttachment;
37
+ userTextFileAttachments?: UserTextFileAttachment[];
37
38
  };
38
39
 
39
40
  export interface ChatContextType {
@@ -300,14 +301,18 @@ export function ChatProvider({
300
301
  if (userSwitchKey === null) return undefined;
301
302
  addScopeIdToRegistry(scopeId);
302
303
  const storedText = stripJsonDashboardFences(text);
303
- const attachment =
304
- role === MessageRole.USER ? options?.userCsvAttachment : undefined;
304
+ const attachments =
305
+ role === MessageRole.USER
306
+ ? options?.userTextFileAttachments
307
+ : undefined;
305
308
  const newMessage: Message = {
306
309
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
307
310
  role,
308
311
  text: storedText,
309
312
  timestamp: Date.now(),
310
- ...(attachment ? { userCsvAttachment: attachment } : {}),
313
+ ...(attachments?.length
314
+ ? { userTextFileAttachments: attachments }
315
+ : {}),
311
316
  };
312
317
 
313
318
  setChats(prev => {
@@ -372,7 +377,7 @@ export function ChatProvider({
372
377
  MessageRole.USER,
373
378
  message.displayText,
374
379
  {
375
- userCsvAttachment: message.userCsvAttachment,
380
+ userTextFileAttachments: normalizeUserTextFileAttachments(message),
376
381
  },
377
382
  );
378
383
  }
@@ -42,11 +42,13 @@ function makeUserMessageWithCsv(
42
42
  role: MessageRole.USER,
43
43
  text: displayText,
44
44
  timestamp: Date.now(),
45
- userCsvAttachment: {
46
- displayName,
47
- filename: 'docs-sample.csv',
48
- content: SAMPLE_CSV,
49
- },
45
+ userTextFileAttachments: [
46
+ {
47
+ displayName,
48
+ filename: 'docs-sample.csv',
49
+ content: SAMPLE_CSV,
50
+ },
51
+ ],
50
52
  };
51
53
  }
52
54
 
@@ -1,10 +0,0 @@
1
- import { jsx } from 'react/jsx-runtime';
2
- import { FileChip } from '../../FileChip/FileChip.js';
3
- import { downloadTextFile } from '../../../../utils/downloadTextFile.js';
4
-
5
- const CSV_DOWNLOAD_HINT = 'Download .CSV file';
6
- function UserCsvAttachmentBubble({ attachment, }) {
7
- return (jsx(FileChip, { name: attachment.displayName, format: "csv", hint: CSV_DOWNLOAD_HINT, onClick: () => downloadTextFile(attachment.content, attachment.filename, 'text/csv;charset=utf-8') }));
8
- }
9
-
10
- export { UserCsvAttachmentBubble };
@@ -1,4 +0,0 @@
1
- import type { UserCsvAttachment } from '../Chat.types';
2
- export declare function UserCsvAttachmentBubble({ attachment, }: {
3
- attachment: UserCsvAttachment;
4
- }): import("react/jsx-runtime").JSX.Element;
@@ -1,27 +0,0 @@
1
- import { FileChip } from '#uilib/components/ui/FileChip';
2
- import { downloadTextFile } from '#uilib/utils/downloadTextFile';
3
-
4
- import type { UserCsvAttachment } from '../Chat.types';
5
-
6
- const CSV_DOWNLOAD_HINT = 'Download .CSV file';
7
-
8
- export function UserCsvAttachmentBubble({
9
- attachment,
10
- }: {
11
- attachment: UserCsvAttachment;
12
- }) {
13
- return (
14
- <FileChip
15
- name={attachment.displayName}
16
- format="csv"
17
- hint={CSV_DOWNLOAD_HINT}
18
- onClick={() =>
19
- downloadTextFile(
20
- attachment.content,
21
- attachment.filename,
22
- 'text/csv;charset=utf-8',
23
- )
24
- }
25
- />
26
- );
27
- }