@sybilion/uilib 1.3.14 → 1.3.16

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 (49) hide show
  1. package/dist/esm/components/ui/Chat/Chat.styl.js +1 -1
  2. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +3 -0
  3. package/dist/esm/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.js +6 -3
  4. package/dist/esm/components/ui/Chat/ChatMessage/AgentMessageContent.js +2 -1
  5. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +2 -0
  6. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.styl.js +1 -1
  7. package/dist/esm/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.js +9 -2
  8. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptAttachments.js +7 -1
  9. package/dist/esm/components/ui/Chat/buildChatSendMessagePayload.js +4 -0
  10. package/dist/esm/components/ui/Chat/chatAttachmentAccept.js +20 -1
  11. package/dist/esm/components/ui/Chat/chatAttachmentExtract.js +11 -1
  12. package/dist/esm/components/ui/Chat/chatDocxExtract.js +17 -0
  13. package/dist/esm/components/ui/Chat/chatXlsxExtract.js +34 -0
  14. package/dist/esm/components/ui/InteractiveContent/AutolinkUrl.js +34 -0
  15. package/dist/esm/components/ui/InteractiveContent/InteractiveContent.styl.js +2 -2
  16. package/dist/esm/index.js +1 -0
  17. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +2 -2
  18. package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.d.ts +3 -1
  19. package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.test.d.ts +1 -0
  20. package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.d.ts +2 -0
  21. package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.test.d.ts +1 -0
  22. package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.d.ts +2 -0
  23. package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.test.d.ts +1 -0
  24. package/dist/esm/types/src/components/ui/InteractiveContent/AutolinkUrl.d.ts +11 -0
  25. package/dist/esm/types/src/components/ui/InteractiveContent/index.d.ts +1 -0
  26. package/dist/esm/types/tests/setup.d.ts +1 -0
  27. package/package.json +4 -2
  28. package/src/components/ui/Chat/Chat.styl +1 -0
  29. package/src/components/ui/Chat/Chat.types.ts +2 -2
  30. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +3 -0
  31. package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.tsx +35 -7
  32. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl +8 -0
  33. package/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.tsx +6 -2
  34. package/src/components/ui/Chat/ChatPrompt/ChatPromptAttachments.tsx +9 -1
  35. package/src/components/ui/Chat/buildChatSendMessagePayload.test.ts +15 -1
  36. package/src/components/ui/Chat/buildChatSendMessagePayload.ts +2 -0
  37. package/src/components/ui/Chat/chatAttachmentAccept.test.ts +78 -0
  38. package/src/components/ui/Chat/chatAttachmentAccept.ts +25 -0
  39. package/src/components/ui/Chat/chatAttachmentExtract.ts +13 -1
  40. package/src/components/ui/Chat/chatDocxExtract.test.ts +40 -0
  41. package/src/components/ui/Chat/chatDocxExtract.ts +19 -0
  42. package/src/components/ui/Chat/chatXlsxExtract.test.ts +72 -0
  43. package/src/components/ui/Chat/chatXlsxExtract.ts +43 -0
  44. package/src/components/ui/InteractiveContent/AutolinkUrl.tsx +53 -0
  45. package/src/components/ui/InteractiveContent/InteractiveContent.styl +60 -9
  46. package/src/components/ui/InteractiveContent/InteractiveContent.styl.d.ts +4 -0
  47. package/src/components/ui/InteractiveContent/index.ts +1 -0
  48. package/src/docs/pages/ChatAttachmentsDropzonePage.tsx +14 -20
  49. package/src/docs/pages/InteractiveContentPage.tsx +2 -1
@@ -1,6 +1,6 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".Chat_root__IWt99{background-color:var(--background);display:flex;flex-direction:column;height:100%;min-height:0}.Chat_header__ZjwP-{align-items:center;display:flex;flex-shrink:0;min-height:64px;padding:var(--p-2) var(--p-6) 0}.Chat_isEmpty__b4ViB{padding-bottom:170px}";
3
+ var css_248z = ".Chat_root__IWt99{background-color:var(--background);display:flex;flex-direction:column;height:100%;min-height:0}.Chat_header__ZjwP-{align-items:center;display:flex;flex-shrink:0;min-height:64px;padding:var(--p-2) var(--p-6) 0;padding-right:var(--p-12)}.Chat_isEmpty__b4ViB{padding-bottom:170px}";
4
4
  var S = {"root":"Chat_root__IWt99","header":"Chat_header__ZjwP-","isEmpty":"Chat_isEmpty__b4ViB"};
5
5
  styleInject(css_248z);
6
6
 
@@ -30,6 +30,9 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
30
30
  if (items.length > 0) {
31
31
  setPendingAttachments(prev => [...prev, ...items]);
32
32
  }
33
+ })
34
+ .catch(() => {
35
+ // Extraction failed (parse error, size limit, etc.); skip staging.
33
36
  })
34
37
  .finally(() => setIsExtractingAttachments(false));
35
38
  }, [allowPdfAttachments, promptBusy]);
@@ -1,6 +1,8 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
+ import { shouldRenderAutolinkPill, AutolinkUrl } from '../../InteractiveContent/AutolinkUrl.js';
2
3
  import logger from '../../../../lib/logger.js';
3
4
  import S from './ChatMessage.styl.js';
5
+ import S$1 from '../../InteractiveContent/InteractiveContent.styl.js';
4
6
 
5
7
  const injectHeaders = (content) => {
6
8
  // Match #, ##, ###, or #### headers at start of line
@@ -320,7 +322,7 @@ const injectMarkdownLink = (content) => {
320
322
  const { target, rel } = linkTargetRelForHref(href, undefined);
321
323
  const label = matches[1];
322
324
  return {
323
- elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(label, linkLabelInjectors) })),
325
+ elem: shouldRenderAutolinkPill(label, href) ? (jsx(AutolinkUrl, { href: href, display: label, target: target, rel: rel })) : (jsx("a", { href: href, target: target, rel: rel, className: S$1.textLink, children: runFormattingPipeline(label, linkLabelInjectors) })),
324
326
  index: matches.index,
325
327
  length: matches[0].length,
326
328
  };
@@ -366,7 +368,7 @@ const injectAutolinkUrl = (content) => {
366
368
  }
367
369
  const { target, rel } = linkTargetRelForHref(c.href, undefined);
368
370
  return {
369
- elem: (jsx("a", { href: c.href, target: target, rel: rel, children: c.display })),
371
+ elem: (jsx(AutolinkUrl, { href: c.href, display: c.display, target: target, rel: rel })),
370
372
  index: c.index,
371
373
  length: c.length,
372
374
  };
@@ -393,8 +395,9 @@ const injectAnchor = (content) => {
393
395
  return null;
394
396
  }
395
397
  const { target, rel } = linkTargetRelForHref(href, targetRaw || undefined);
398
+ const innerTrimmed = inner.trim();
396
399
  return {
397
- elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(inner, linkLabelInjectors) })),
400
+ elem: shouldRenderAutolinkPill(innerTrimmed, href) ? (jsx(AutolinkUrl, { href: href, display: innerTrimmed, target: target, rel: rel })) : (jsx("a", { href: href, target: target, rel: rel, className: S$1.textLink, children: runFormattingPipeline(inner, linkLabelInjectors) })),
398
401
  index: matches.index,
399
402
  length: matches[0].length,
400
403
  };
@@ -2,10 +2,11 @@ import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { useState, useMemo } from 'react';
3
3
  import { Button } from '../../Button/Button.js';
4
4
  import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.js';
5
+ import { Presentation } from 'lucide-react';
6
+ import '../../InteractiveContent/InteractiveContent.styl.js';
5
7
  import { parseDatasetAppToken } from '../../../../utils/datasetApplicationLink.js';
6
8
  import { Scroll } from '@homecode/ui';
7
9
  import { PaperPlaneRightIcon } from '@phosphor-icons/react';
8
- import { Presentation } from 'lucide-react';
9
10
  import { convertMarkdownTableToHTML } from './AgentMessageContent.helpers.js';
10
11
  import { ChatDatasetApplicationLink } from './ChatDatasetApplicationLink.js';
11
12
  import S from './ChatMessage.styl.js';
@@ -1,6 +1,8 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import cn from 'classnames';
3
3
  import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.js';
4
+ import 'lucide-react';
5
+ import '../../InteractiveContent/InteractiveContent.styl.js';
4
6
  import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
5
7
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
6
8
  import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments.js';
@@ -1,6 +1,6 @@
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_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-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}";
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%;min-width:0;overflow-wrap:anywhere;-webkit-user-select:text;-moz-user-select:text;user-select:text;width:-moz-fit-content;width:fit-content;word-break:break-word}.ChatMessage_role-user__u4JPV{align-items:flex-end;max-width:100%;min-width:0}.ChatMessage_role-user__u4JPV .ChatMessage_userColumn__cQM6-{align-items:flex-end;display:flex;flex-direction:column;gap:var(--p-2);max-width:100%;min-width:0}.ChatMessage_role-user__u4JPV .ChatMessage_text__Y1XNR{background-color:var(--sb-slate-100);border-radius:var(--p-4);border-bottom-right-radius:0;box-sizing:border-box;overflow:hidden;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
4
  var S = {"root":"ChatMessage_root__6rnsF","text":"ChatMessage_text__Y1XNR","role-user":"ChatMessage_role-user__u4JPV","userColumn":"ChatMessage_userColumn__cQM6-","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
 
@@ -8,6 +8,8 @@ function formatFromFilename(filename) {
8
8
  return 'csv';
9
9
  if (lower.endsWith('.pdf'))
10
10
  return 'pdf';
11
+ if (lower.endsWith('.xlsx'))
12
+ return 'text';
11
13
  return 'text';
12
14
  }
13
15
  function mimeForFormat(format) {
@@ -17,16 +19,21 @@ function mimeForFormat(format) {
17
19
  return 'application/pdf';
18
20
  return 'text/plain;charset=utf-8';
19
21
  }
20
- function hintForFormat(format) {
22
+ function hintForFormat(format, filename) {
23
+ const lower = filename.toLowerCase();
21
24
  if (format === 'csv')
22
25
  return 'Download .CSV file';
23
26
  if (format === 'pdf')
24
27
  return 'Download file';
28
+ if (lower.endsWith('.docx'))
29
+ return 'Download Word document';
30
+ if (lower.endsWith('.xlsx'))
31
+ return 'Download spreadsheet';
25
32
  return 'Download text file';
26
33
  }
27
34
  function UserTextFileAttachmentBubble({ attachment, }) {
28
35
  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)) }));
36
+ return (jsx(FileChip, { name: attachment.displayName, format: format, hint: hintForFormat(format, attachment.filename), onClick: () => downloadTextFile(attachment.content, attachment.filename, mimeForFormat(format)) }));
30
37
  }
31
38
 
32
39
  export { UserTextFileAttachmentBubble };
@@ -5,7 +5,13 @@ import S from './ChatPrompt.styl.js';
5
5
  function ChatPromptAttachments({ attachments, onRemove, disabled = false, }) {
6
6
  if (attachments.length === 0)
7
7
  return null;
8
- return (jsx("div", { className: S.attachments, children: attachments.map((item, index) => (jsx(FileChip, { className: S.attachmentItem, name: item.file.name, format: item.kind === 'pdf' ? 'pdf' : 'text', hint: item.kind === 'pdf' ? 'PDF' : 'Text file', onRemove: () => onRemove(index), disabled: disabled }, `${item.file.name}-${index}`))) }));
8
+ return (jsx("div", { className: S.attachments, children: attachments.map((item, index) => (jsx(FileChip, { className: S.attachmentItem, name: item.file.name, format: item.kind === 'pdf' ? 'pdf' : 'text', hint: item.kind === 'pdf'
9
+ ? 'PDF'
10
+ : item.kind === 'docx'
11
+ ? 'Word document'
12
+ : item.kind === 'xlsx'
13
+ ? 'Spreadsheet'
14
+ : 'Text file', onRemove: () => onRemove(index), disabled: disabled }, `${item.file.name}-${index}`))) }));
9
15
  }
10
16
 
11
17
  export { ChatPromptAttachments };
@@ -4,6 +4,10 @@ function defaultExtForAttachment(item) {
4
4
  const name = item.file.name.toLowerCase();
5
5
  if (item.kind === 'pdf' || name.endsWith('.pdf'))
6
6
  return 'pdf';
7
+ if (item.kind === 'docx' || name.endsWith('.docx'))
8
+ return 'docx';
9
+ if (item.kind === 'xlsx' || name.endsWith('.xlsx'))
10
+ return 'xlsx';
7
11
  if (name.endsWith('.csv'))
8
12
  return 'csv';
9
13
  if (name.endsWith('.json'))
@@ -24,6 +24,10 @@ const TEXT_ATTACHMENT_ACCEPT_PARTS = [
24
24
  '.tsv',
25
25
  'text/calendar',
26
26
  '.ics',
27
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
28
+ '.docx',
29
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
30
+ '.xlsx',
27
31
  ];
28
32
  const PDF_ATTACHMENT_ACCEPT_PARTS = ['application/pdf', '.pdf'];
29
33
  const TEXT_ATTACHMENT_ACCEPT_SET = new Set(TEXT_ATTACHMENT_ACCEPT_PARTS.map(part => part.toLowerCase()));
@@ -46,9 +50,24 @@ function isPdfFile(file) {
46
50
  return true;
47
51
  return file.name.toLowerCase().endsWith('.pdf');
48
52
  }
53
+ function isDocxFile(file) {
54
+ const type = file.type.toLowerCase();
55
+ if (type ===
56
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
57
+ return true;
58
+ }
59
+ return file.name.toLowerCase().endsWith('.docx');
60
+ }
61
+ function isXlsxFile(file) {
62
+ const type = file.type.toLowerCase();
63
+ if (type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
64
+ return true;
65
+ }
66
+ return file.name.toLowerCase().endsWith('.xlsx');
67
+ }
49
68
  function isAttachmentsDropzoneEnabled(allowedAttachments, allowPdfAttachments) {
50
69
  return (filterToTextAttachments(allowedAttachments).length > 0 ||
51
70
  Boolean(allowPdfAttachments));
52
71
  }
53
72
 
54
- export { PDF_ATTACHMENT_ACCEPT_PARTS, TEXT_ATTACHMENT_ACCEPT_PARTS, buildAcceptAttr, filterToTextAttachments, isAttachmentsDropzoneEnabled, isPdfFile };
73
+ export { PDF_ATTACHMENT_ACCEPT_PARTS, TEXT_ATTACHMENT_ACCEPT_PARTS, buildAcceptAttr, filterToTextAttachments, isAttachmentsDropzoneEnabled, isDocxFile, isPdfFile, isXlsxFile };
@@ -1,5 +1,7 @@
1
- import { isPdfFile } from './chatAttachmentAccept.js';
1
+ import { isPdfFile, isDocxFile, isXlsxFile } from './chatAttachmentAccept.js';
2
+ import { extractDocxFileToText } from './chatDocxExtract.js';
2
3
  import { extractPdfFileToText } from './chatPdfExtract.js';
4
+ import { extractXlsxFileToText } from './chatXlsxExtract.js';
3
5
 
4
6
  function readTextFile(file) {
5
7
  return new Promise((resolve, reject) => {
@@ -17,6 +19,14 @@ async function extractChatAttachmentItems(files, allowPdfAttachments) {
17
19
  const text = await extractPdfFileToText(file);
18
20
  return { file, text, kind: 'pdf' };
19
21
  }
22
+ if (isDocxFile(file)) {
23
+ const text = await extractDocxFileToText(file);
24
+ return { file, text, kind: 'docx' };
25
+ }
26
+ if (isXlsxFile(file)) {
27
+ const text = await extractXlsxFileToText(file);
28
+ return { file, text, kind: 'xlsx' };
29
+ }
20
30
  const text = await readTextFile(file);
21
31
  return { file, text, kind: 'text' };
22
32
  }));
@@ -0,0 +1,17 @@
1
+ /** Best-effort plain text from DOCX via mammoth (loaded on demand). */
2
+ async function extractDocxFileToText(file) {
3
+ const mammoth = await import('mammoth');
4
+ const result = await mammoth.extractRawText({
5
+ arrayBuffer: await file.arrayBuffer(),
6
+ });
7
+ const errors = result.messages.filter(m => m.type === 'error');
8
+ if (errors.length > 0) {
9
+ const detail = errors.map(m => m.message).join('; ');
10
+ throw new Error(detail
11
+ ? `Failed to read ${file.name}: ${detail}`
12
+ : `Failed to read ${file.name}`);
13
+ }
14
+ return result.value.trim();
15
+ }
16
+
17
+ export { extractDocxFileToText };
@@ -0,0 +1,34 @@
1
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
2
+ const MAX_SHEETS = 20;
3
+ const MAX_CSV_CHARS_PER_SHEET = 500_000;
4
+ function truncateCsv(csv) {
5
+ if (csv.length <= MAX_CSV_CHARS_PER_SHEET)
6
+ return csv;
7
+ return `${csv.slice(0, MAX_CSV_CHARS_PER_SHEET).trimEnd()}…`;
8
+ }
9
+ /** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
10
+ async function extractXlsxFileToText(file) {
11
+ const buffer = new Uint8Array(await file.arrayBuffer());
12
+ if (buffer.byteLength > MAX_FILE_BYTES) {
13
+ throw new Error(`${file.name} is too large (max ${MAX_FILE_BYTES / (1024 * 1024)} MB)`);
14
+ }
15
+ const XLSX = await import('xlsx');
16
+ const workbook = XLSX.read(buffer, { type: 'array' });
17
+ const sheetNames = workbook.SheetNames.slice(0, MAX_SHEETS);
18
+ const sheetTexts = [];
19
+ for (const sheetName of sheetNames) {
20
+ const sheet = workbook.Sheets[sheetName];
21
+ if (!sheet)
22
+ continue;
23
+ const csv = truncateCsv(XLSX.utils.sheet_to_csv(sheet, { blankrows: false }).trim());
24
+ if (csv) {
25
+ sheetTexts.push(`## Sheet ${sheetName}\n\n${csv}`);
26
+ }
27
+ }
28
+ if (workbook.SheetNames.length > MAX_SHEETS) {
29
+ sheetTexts.push(`_(Only the first ${MAX_SHEETS} of ${workbook.SheetNames.length} sheets were included.)_`);
30
+ }
31
+ return sheetTexts.join('\n\n');
32
+ }
33
+
34
+ export { extractXlsxFileToText };
@@ -0,0 +1,34 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { LinkIcon } from 'lucide-react';
3
+ import S from './InteractiveContent.styl.js';
4
+
5
+ function displayUrlForAutolink(display) {
6
+ if (display.startsWith('https://'))
7
+ return display.slice(8);
8
+ if (display.startsWith('http://'))
9
+ return display.slice(7);
10
+ return display;
11
+ }
12
+ function shouldRenderAutolinkPill(label, href) {
13
+ const normalizedLabel = label.trim();
14
+ const normalizedHref = href.trim();
15
+ if (!normalizedLabel || !normalizedHref)
16
+ return false;
17
+ const labelAsHref = normalizeWwwToHttps(normalizedLabel);
18
+ if (labelAsHref === normalizedHref)
19
+ return true;
20
+ return (/^https?:\/\//i.test(normalizedLabel) ||
21
+ /^www\./i.test(normalizedLabel) ||
22
+ normalizedLabel === displayUrlForAutolink(normalizedHref));
23
+ }
24
+ const normalizeWwwToHttps = (raw) => {
25
+ const t = raw.trim();
26
+ if (/^www\./i.test(t))
27
+ return `https://${t}`;
28
+ return t;
29
+ };
30
+ function AutolinkUrl({ href, display, target, rel }) {
31
+ return (jsxs("a", { href: href, target: target, rel: rel, className: S.autolinkUrl, children: [jsx(LinkIcon, { size: 12, "aria-hidden": true, className: S.autolinkUrlIcon }), jsx("span", { className: S.autolinkUrlLabel, title: displayUrlForAutolink(display), children: displayUrlForAutolink(display) })] }));
32
+ }
33
+
34
+ export { AutolinkUrl, S as autolinkStyles, displayUrlForAutolink, shouldRenderAutolinkPill };
@@ -1,7 +1,7 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".InteractiveContent_root__FHnlY strong{font-weight:600}.InteractiveContent_root__FHnlY em{font-style:italic}.InteractiveContent_root__FHnlY a{color:var(--sb-green-600);text-decoration:underline;text-underline-offset:2px}.InteractiveContent_root__FHnlY a:hover{color:var(--sb-green-700)}.dark .InteractiveContent_root__FHnlY a{color:var(--sb-green-400)}.dark .InteractiveContent_root__FHnlY a:hover{color:var(--sb-green-300)}";
4
- var S = {"root":"InteractiveContent_root__FHnlY"};
3
+ var css_248z = ".InteractiveContent_root__FHnlY{box-sizing:border-box;display:inline-block;max-width:100%;min-width:0;overflow-wrap:anywhere;word-break:break-word}.InteractiveContent_root__FHnlY strong{font-weight:600}.InteractiveContent_root__FHnlY em{font-style:italic}.InteractiveContent_textLink__Ubh4i{color:var(--sb-green-600);text-decoration:underline;text-underline-offset:2px}.InteractiveContent_textLink__Ubh4i:hover{color:var(--sb-green-700)}.dark .InteractiveContent_textLink__Ubh4i{color:var(--sb-green-400)}.dark .InteractiveContent_textLink__Ubh4i:hover{color:var(--sb-green-300)}.InteractiveContent_autolinkUrl__W0tQH{align-items:center;border-radius:var(--p-2);box-sizing:border-box;color:var(--link-color);display:inline-flex;font-size:var(--text-xs);gap:6px;line-height:1.4;margin:0 1px;max-width:100%;padding:2px 8px 2px 6px;position:relative;text-decoration:none;vertical-align:middle}.InteractiveContent_autolinkUrl__W0tQH:before{background-color:var(--link-color);border-radius:inherit;content:\"\";display:inline-block;height:100%;left:0;opacity:.1;position:absolute;top:0;width:100%}.InteractiveContent_autolinkUrl__W0tQH:hover:before{opacity:.2}.InteractiveContent_autolinkUrlIcon__QoqlQ{flex-shrink:0}.InteractiveContent_autolinkUrlLabel__CcJOG{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}";
4
+ var S = {"root":"InteractiveContent_root__FHnlY","textLink":"InteractiveContent_textLink__Ubh4i","autolinkUrl":"InteractiveContent_autolinkUrl__W0tQH","autolinkUrlIcon":"InteractiveContent_autolinkUrlIcon__QoqlQ","autolinkUrlLabel":"InteractiveContent_autolinkUrlLabel__CcJOG"};
5
5
  styleInject(css_248z);
6
6
 
7
7
  export { S as default };
package/dist/esm/index.js CHANGED
@@ -47,6 +47,7 @@ export { Image } from './components/ui/Image/Image.js';
47
47
  export { ImageWithFallback } from './components/ui/ImageWithFallback/ImageWithFallback.js';
48
48
  export { Input } from './components/ui/Input/Input.js';
49
49
  export { InteractiveContent } from './components/ui/InteractiveContent/InteractiveContent.js';
50
+ export { AutolinkUrl, displayUrlForAutolink } from './components/ui/InteractiveContent/AutolinkUrl.js';
50
51
  export { Label } from './components/ui/Label/Label.js';
51
52
  export { LabeledInput } from './components/ui/LabeledInput/LabeledInput.js';
52
53
  export { AltKeyProvider } from './components/ui/LabelWithId/AltKeyProvider.js';
@@ -59,9 +59,9 @@ export type ScriptCompletePayload = {
59
59
  };
60
60
  export type ChatAttachmentDropItem = {
61
61
  file: File;
62
- /** UTF-8 text for native text files; PDF yields extracted text. */
62
+ /** UTF-8 text for native text files; PDF/DOCX/XLSX yield extracted text. */
63
63
  text: string;
64
- kind: 'text' | 'pdf';
64
+ kind: 'text' | 'pdf' | 'docx' | 'xlsx';
65
65
  };
66
66
  export interface ChatPromptProps {
67
67
  className?: string;
@@ -1,8 +1,10 @@
1
1
  /** MIME types and extensions accepted for chat text attachments. */
2
- export declare const TEXT_ATTACHMENT_ACCEPT_PARTS: readonly ["text/plain", ".txt", "text/csv", ".csv", "text/markdown", ".md", ".markdown", "application/json", ".json", "text/html", ".html", ".htm", "text/xml", "application/xml", ".xml", "text/yaml", "application/yaml", "application/x-yaml", ".yaml", ".yml", "text/tab-separated-values", ".tsv", "text/calendar", ".ics"];
2
+ export declare const TEXT_ATTACHMENT_ACCEPT_PARTS: readonly ["text/plain", ".txt", "text/csv", ".csv", "text/markdown", ".md", ".markdown", "application/json", ".json", "text/html", ".html", ".htm", "text/xml", "application/xml", ".xml", "text/yaml", "application/yaml", "application/x-yaml", ".yaml", ".yml", "text/tab-separated-values", ".tsv", "text/calendar", ".ics", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"];
3
3
  export declare const PDF_ATTACHMENT_ACCEPT_PARTS: readonly ["application/pdf", ".pdf"];
4
4
  /** Keep only tokens from `parts` that appear in the text attachment allowlist. */
5
5
  export declare function filterToTextAttachments(parts: readonly string[] | undefined): string[];
6
6
  export declare function buildAcceptAttr(filteredTextParts: readonly string[], allowPdf: boolean): string;
7
7
  export declare function isPdfFile(file: File): boolean;
8
+ export declare function isDocxFile(file: File): boolean;
9
+ export declare function isXlsxFile(file: File): boolean;
8
10
  export declare function isAttachmentsDropzoneEnabled(allowedAttachments: readonly string[] | undefined, allowPdfAttachments: boolean | undefined): boolean;
@@ -0,0 +1,2 @@
1
+ /** Best-effort plain text from DOCX via mammoth (loaded on demand). */
2
+ export declare function extractDocxFileToText(file: File): Promise<string>;
@@ -0,0 +1,2 @@
1
+ /** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
2
+ export declare function extractXlsxFileToText(file: File): Promise<string>;
@@ -0,0 +1,11 @@
1
+ import S from './InteractiveContent.styl';
2
+ type AutolinkUrlProps = {
3
+ href: string;
4
+ display: string;
5
+ target?: string;
6
+ rel?: string;
7
+ };
8
+ export declare function displayUrlForAutolink(display: string): string;
9
+ export declare function shouldRenderAutolinkPill(label: string, href: string): boolean;
10
+ export declare function AutolinkUrl({ href, display, target, rel }: AutolinkUrlProps): import("react/jsx-runtime").JSX.Element;
11
+ export { S as autolinkStyles };
@@ -1 +1,2 @@
1
1
  export { InteractiveContent } from './InteractiveContent';
2
+ export { AutolinkUrl, displayUrlForAutolink } from './AutolinkUrl';
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.14",
3
+ "version": "1.3.16",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -102,6 +102,7 @@
102
102
  "classnames": "^2.3.2",
103
103
  "lightweight-charts": "^5.0.9",
104
104
  "lucide-react": "^0.546.0",
105
+ "mammoth": "^1.9.0",
105
106
  "motion": "^12.23.12",
106
107
  "pdfjs-dist": "^4.10.38",
107
108
  "recharts": "^3.2.1",
@@ -109,7 +110,8 @@
109
110
  "style-inject": "^0.3.0",
110
111
  "tailwindcss": "^4.2.2",
111
112
  "tslib": "^2.8.1",
112
- "vaul": "^1.1.2"
113
+ "vaul": "^1.1.2",
114
+ "xlsx": "^0.18.5"
113
115
  },
114
116
  "peerDependencies": {
115
117
  "@auth0/auth0-react": "^2.3.1",
@@ -12,6 +12,7 @@
12
12
  align-items center
13
13
  min-height 64px
14
14
  padding var(--p-2) var(--p-6) 0
15
+ padding-right var(--p-12) // to not overlap with ChatSheet close button
15
16
 
16
17
  .isEmpty
17
18
  padding-bottom 170px // goes under prompt
@@ -69,9 +69,9 @@ export type ScriptCompletePayload = {
69
69
 
70
70
  export type ChatAttachmentDropItem = {
71
71
  file: File;
72
- /** UTF-8 text for native text files; PDF yields extracted text. */
72
+ /** UTF-8 text for native text files; PDF/DOCX/XLSX yield extracted text. */
73
73
  text: string;
74
- kind: 'text' | 'pdf';
74
+ kind: 'text' | 'pdf' | 'docx' | 'xlsx';
75
75
  };
76
76
 
77
77
  export interface ChatPromptProps {
@@ -86,6 +86,9 @@ export function ChatChrome({
86
86
  setPendingAttachments(prev => [...prev, ...items]);
87
87
  }
88
88
  })
89
+ .catch(() => {
90
+ // Extraction failed (parse error, size limit, etc.); skip staging.
91
+ })
89
92
  .finally(() => setIsExtractingAttachments(false));
90
93
  },
91
94
  [allowPdfAttachments, promptBusy],
@@ -1,5 +1,10 @@
1
1
  import React from 'react';
2
2
 
3
+ import {
4
+ AutolinkUrl,
5
+ autolinkStyles,
6
+ shouldRenderAutolinkPill,
7
+ } from '#uilib/components/ui/InteractiveContent/AutolinkUrl';
3
8
  import logger from '#uilib/lib/logger';
4
9
 
5
10
  import S from './ChatMessage.styl';
@@ -397,8 +402,15 @@ const injectMarkdownLink: Injector = (content: string) => {
397
402
  const label = matches[1];
398
403
 
399
404
  return {
400
- elem: (
401
- <a href={href} target={target} rel={rel}>
405
+ elem: shouldRenderAutolinkPill(label, href) ? (
406
+ <AutolinkUrl href={href} display={label} target={target} rel={rel} />
407
+ ) : (
408
+ <a
409
+ href={href}
410
+ target={target}
411
+ rel={rel}
412
+ className={autolinkStyles.textLink}
413
+ >
402
414
  {runFormattingPipeline(label, linkLabelInjectors)}
403
415
  </a>
404
416
  ),
@@ -459,9 +471,12 @@ const injectAutolinkUrl: Injector = (content: string) => {
459
471
 
460
472
  return {
461
473
  elem: (
462
- <a href={c.href} target={target} rel={rel}>
463
- {c.display}
464
- </a>
474
+ <AutolinkUrl
475
+ href={c.href}
476
+ display={c.display}
477
+ target={target}
478
+ rel={rel}
479
+ />
465
480
  ),
466
481
  index: c.index,
467
482
  length: c.length,
@@ -500,10 +515,23 @@ const injectAnchor: Injector = (content: string) => {
500
515
  }
501
516
 
502
517
  const { target, rel } = linkTargetRelForHref(href, targetRaw || undefined);
518
+ const innerTrimmed = inner.trim();
503
519
 
504
520
  return {
505
- elem: (
506
- <a href={href} target={target} rel={rel}>
521
+ elem: shouldRenderAutolinkPill(innerTrimmed, href) ? (
522
+ <AutolinkUrl
523
+ href={href}
524
+ display={innerTrimmed}
525
+ target={target}
526
+ rel={rel}
527
+ />
528
+ ) : (
529
+ <a
530
+ href={href}
531
+ target={target}
532
+ rel={rel}
533
+ className={autolinkStyles.textLink}
534
+ >
507
535
  {runFormattingPipeline(inner, linkLabelInjectors)}
508
536
  </a>
509
537
  ),
@@ -8,13 +8,18 @@
8
8
 
9
9
  .text
10
10
  max-width 100%
11
+ min-width 0
11
12
  font-size var(--text-sm)
12
13
  color var(--text-secondary)
13
14
  width fit-content
14
15
  user-select text
16
+ overflow-wrap anywhere
17
+ word-break break-word
15
18
 
16
19
  .role-user
17
20
  align-items flex-end
21
+ max-width 100%
22
+ min-width 0
18
23
 
19
24
  .userColumn
20
25
  display flex
@@ -22,6 +27,7 @@
22
27
  align-items flex-end
23
28
  gap var(--p-2)
24
29
  max-width 100%
30
+ min-width 0
25
31
 
26
32
  .text
27
33
  padding var(--p-3) var(--p-4)
@@ -31,6 +37,8 @@
31
37
  border-bottom-right-radius 0
32
38
 
33
39
  white-space pre-wrap
40
+ overflow hidden
41
+ box-sizing border-box
34
42
 
35
43
  :global(.dark) &
36
44
  background-color var(--sb-gray-800)
@@ -7,6 +7,7 @@ function formatFromFilename(filename: string): FileChipFormat {
7
7
  const lower = filename.toLowerCase();
8
8
  if (lower.endsWith('.csv')) return 'csv';
9
9
  if (lower.endsWith('.pdf')) return 'pdf';
10
+ if (lower.endsWith('.xlsx')) return 'text';
10
11
  return 'text';
11
12
  }
12
13
 
@@ -16,9 +17,12 @@ function mimeForFormat(format: FileChipFormat): string {
16
17
  return 'text/plain;charset=utf-8';
17
18
  }
18
19
 
19
- function hintForFormat(format: FileChipFormat): string {
20
+ function hintForFormat(format: FileChipFormat, filename: string): string {
21
+ const lower = filename.toLowerCase();
20
22
  if (format === 'csv') return 'Download .CSV file';
21
23
  if (format === 'pdf') return 'Download file';
24
+ if (lower.endsWith('.docx')) return 'Download Word document';
25
+ if (lower.endsWith('.xlsx')) return 'Download spreadsheet';
22
26
  return 'Download text file';
23
27
  }
24
28
 
@@ -33,7 +37,7 @@ export function UserTextFileAttachmentBubble({
33
37
  <FileChip
34
38
  name={attachment.displayName}
35
39
  format={format}
36
- hint={hintForFormat(format)}
40
+ hint={hintForFormat(format, attachment.filename)}
37
41
  onClick={() =>
38
42
  downloadTextFile(
39
43
  attachment.content,
@@ -24,7 +24,15 @@ export function ChatPromptAttachments({
24
24
  className={S.attachmentItem}
25
25
  name={item.file.name}
26
26
  format={item.kind === 'pdf' ? 'pdf' : 'text'}
27
- hint={item.kind === 'pdf' ? 'PDF' : 'Text file'}
27
+ hint={
28
+ item.kind === 'pdf'
29
+ ? 'PDF'
30
+ : item.kind === 'docx'
31
+ ? 'Word document'
32
+ : item.kind === 'xlsx'
33
+ ? 'Spreadsheet'
34
+ : 'Text file'
35
+ }
28
36
  onRemove={() => onRemove(index)}
29
37
  disabled={disabled}
30
38
  />
@@ -8,7 +8,7 @@ import {
8
8
  function makeDropItem(
9
9
  name: string,
10
10
  text: string,
11
- kind: 'text' | 'pdf' = 'text',
11
+ kind: ChatAttachmentDropItem['kind'] = 'text',
12
12
  ): ChatAttachmentDropItem {
13
13
  return {
14
14
  file: { name } as File,
@@ -50,6 +50,20 @@ describe('buildChatSendMessagePayload', () => {
50
50
  expect(result.userTextFileAttachments?.[0].filename).toMatch(/\.pdf$/i);
51
51
  });
52
52
 
53
+ it('uses docx and xlsx extensions from kind', () => {
54
+ const docx = buildChatSendMessagePayload('', [
55
+ makeDropItem('brief.docx', 'word body', 'docx'),
56
+ ]);
57
+ const xlsx = buildChatSendMessagePayload('', [
58
+ makeDropItem('sheet.xlsx', 'a,b', 'xlsx'),
59
+ ]);
60
+ if (typeof docx === 'string' || typeof xlsx === 'string') {
61
+ throw new Error('expected payload object');
62
+ }
63
+ expect(docx.userTextFileAttachments?.[0].filename).toMatch(/\.docx$/i);
64
+ expect(xlsx.userTextFileAttachments?.[0].filename).toMatch(/\.xlsx$/i);
65
+ });
66
+
53
67
  it('maps multiple attachments', () => {
54
68
  const result = buildChatSendMessagePayload('Hi', [
55
69
  makeDropItem('one.txt', 'first'),
@@ -8,6 +8,8 @@ import { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
8
8
  function defaultExtForAttachment(item: ChatAttachmentDropItem): string {
9
9
  const name = item.file.name.toLowerCase();
10
10
  if (item.kind === 'pdf' || name.endsWith('.pdf')) return 'pdf';
11
+ if (item.kind === 'docx' || name.endsWith('.docx')) return 'docx';
12
+ if (item.kind === 'xlsx' || name.endsWith('.xlsx')) return 'xlsx';
11
13
  if (name.endsWith('.csv')) return 'csv';
12
14
  if (name.endsWith('.json')) return 'json';
13
15
  if (name.endsWith('.md') || name.endsWith('.markdown')) return 'md';
@@ -0,0 +1,78 @@
1
+ import {
2
+ TEXT_ATTACHMENT_ACCEPT_PARTS,
3
+ buildAcceptAttr,
4
+ filterToTextAttachments,
5
+ isDocxFile,
6
+ isPdfFile,
7
+ isXlsxFile,
8
+ } from './chatAttachmentAccept';
9
+
10
+ function makeFile(name: string, type = ''): File {
11
+ return { name, type } as File;
12
+ }
13
+
14
+ describe('TEXT_ATTACHMENT_ACCEPT_PARTS', () => {
15
+ it('includes docx and xlsx MIME types and extensions', () => {
16
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain('.docx');
17
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain('.xlsx');
18
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain(
19
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
+ );
21
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain(
22
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
23
+ );
24
+ });
25
+ });
26
+
27
+ describe('isDocxFile', () => {
28
+ it('detects by extension and MIME type', () => {
29
+ expect(
30
+ isDocxFile(
31
+ makeFile(
32
+ 'notes.docx',
33
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
34
+ ),
35
+ ),
36
+ ).toBe(true);
37
+ expect(isDocxFile(makeFile('notes.docx'))).toBe(true);
38
+ expect(isDocxFile(makeFile('notes.txt'))).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe('isXlsxFile', () => {
43
+ it('detects by extension and MIME type', () => {
44
+ expect(
45
+ isXlsxFile(
46
+ makeFile(
47
+ 'data.xlsx',
48
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49
+ ),
50
+ ),
51
+ ).toBe(true);
52
+ expect(isXlsxFile(makeFile('data.xlsx'))).toBe(true);
53
+ expect(isXlsxFile(makeFile('data.csv'))).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('isPdfFile', () => {
58
+ it('does not treat docx or xlsx as pdf', () => {
59
+ expect(isPdfFile(makeFile('file.docx'))).toBe(false);
60
+ expect(isPdfFile(makeFile('file.xlsx'))).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('filterToTextAttachments', () => {
65
+ it('keeps docx and xlsx tokens from the allowlist', () => {
66
+ expect(
67
+ filterToTextAttachments(['.docx', '.xlsx', 'application/pdf']),
68
+ ).toEqual(['.docx', '.xlsx']);
69
+ });
70
+ });
71
+
72
+ describe('buildAcceptAttr', () => {
73
+ it('includes filtered text parts and optional pdf', () => {
74
+ expect(buildAcceptAttr(['.docx', '.txt'], false)).toBe('.docx,.txt');
75
+ expect(buildAcceptAttr(['.docx'], true)).toContain('.docx');
76
+ expect(buildAcceptAttr(['.docx'], true)).toContain('.pdf');
77
+ });
78
+ });
@@ -24,6 +24,10 @@ export const TEXT_ATTACHMENT_ACCEPT_PARTS = [
24
24
  '.tsv',
25
25
  'text/calendar',
26
26
  '.ics',
27
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
28
+ '.docx',
29
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
30
+ '.xlsx',
27
31
  ] as const;
28
32
 
29
33
  export const PDF_ATTACHMENT_ACCEPT_PARTS = ['application/pdf', '.pdf'] as const;
@@ -59,6 +63,27 @@ export function isPdfFile(file: File): boolean {
59
63
  return file.name.toLowerCase().endsWith('.pdf');
60
64
  }
61
65
 
66
+ export function isDocxFile(file: File): boolean {
67
+ const type = file.type.toLowerCase();
68
+ if (
69
+ type ===
70
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
71
+ ) {
72
+ return true;
73
+ }
74
+ return file.name.toLowerCase().endsWith('.docx');
75
+ }
76
+
77
+ export function isXlsxFile(file: File): boolean {
78
+ const type = file.type.toLowerCase();
79
+ if (
80
+ type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
81
+ ) {
82
+ return true;
83
+ }
84
+ return file.name.toLowerCase().endsWith('.xlsx');
85
+ }
86
+
62
87
  export function isAttachmentsDropzoneEnabled(
63
88
  allowedAttachments: readonly string[] | undefined,
64
89
  allowPdfAttachments: boolean | undefined,
@@ -1,6 +1,8 @@
1
1
  import type { ChatAttachmentDropItem } from './Chat.types';
2
- import { isPdfFile } from './chatAttachmentAccept';
2
+ import { isDocxFile, isPdfFile, isXlsxFile } from './chatAttachmentAccept';
3
+ import { extractDocxFileToText } from './chatDocxExtract';
3
4
  import { extractPdfFileToText } from './chatPdfExtract';
5
+ import { extractXlsxFileToText } from './chatXlsxExtract';
4
6
 
5
7
  function readTextFile(file: File): Promise<string> {
6
8
  return new Promise((resolve, reject) => {
@@ -24,6 +26,16 @@ export async function extractChatAttachmentItems(
24
26
  return { file, text, kind: 'pdf' as const };
25
27
  }
26
28
 
29
+ if (isDocxFile(file)) {
30
+ const text = await extractDocxFileToText(file);
31
+ return { file, text, kind: 'docx' as const };
32
+ }
33
+
34
+ if (isXlsxFile(file)) {
35
+ const text = await extractXlsxFileToText(file);
36
+ return { file, text, kind: 'xlsx' as const };
37
+ }
38
+
27
39
  const text = await readTextFile(file);
28
40
  return { file, text, kind: 'text' as const };
29
41
  }),
@@ -0,0 +1,40 @@
1
+ import { extractDocxFileToText } from './chatDocxExtract';
2
+
3
+ const extractRawText = jest.fn();
4
+
5
+ jest.mock('mammoth', () => ({
6
+ extractRawText: (...args: unknown[]) => extractRawText(...args),
7
+ }));
8
+
9
+ describe('extractDocxFileToText', () => {
10
+ beforeEach(() => {
11
+ extractRawText.mockReset();
12
+ });
13
+
14
+ it('returns trimmed text from mammoth', async () => {
15
+ extractRawText.mockResolvedValue({
16
+ value: ' Hello from Word ',
17
+ messages: [],
18
+ });
19
+
20
+ const file = new File([new Uint8Array(8)], 'doc.docx', {
21
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
22
+ });
23
+
24
+ await expect(extractDocxFileToText(file)).resolves.toBe('Hello from Word');
25
+ expect(extractRawText).toHaveBeenCalledTimes(1);
26
+ });
27
+
28
+ it('throws when mammoth reports errors', async () => {
29
+ extractRawText.mockResolvedValue({
30
+ value: '',
31
+ messages: [{ type: 'error', message: 'corrupt file' }],
32
+ });
33
+
34
+ const file = new File([new Uint8Array(8)], 'bad.docx', {
35
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
36
+ });
37
+
38
+ await expect(extractDocxFileToText(file)).rejects.toThrow(/corrupt file/i);
39
+ });
40
+ });
@@ -0,0 +1,19 @@
1
+ /** Best-effort plain text from DOCX via mammoth (loaded on demand). */
2
+ export async function extractDocxFileToText(file: File): Promise<string> {
3
+ const mammoth = await import('mammoth');
4
+ const result = await mammoth.extractRawText({
5
+ arrayBuffer: await file.arrayBuffer(),
6
+ });
7
+
8
+ const errors = result.messages.filter(m => m.type === 'error');
9
+ if (errors.length > 0) {
10
+ const detail = errors.map(m => m.message).join('; ');
11
+ throw new Error(
12
+ detail
13
+ ? `Failed to read ${file.name}: ${detail}`
14
+ : `Failed to read ${file.name}`,
15
+ );
16
+ }
17
+
18
+ return result.value.trim();
19
+ }
@@ -0,0 +1,72 @@
1
+ import * as XLSX from 'xlsx';
2
+
3
+ import { extractXlsxFileToText } from './chatXlsxExtract';
4
+
5
+ function makeXlsxFile(
6
+ sheets: Record<string, (string | number)[][]>,
7
+ name = 'test.xlsx',
8
+ ): File {
9
+ const workbook = XLSX.utils.book_new();
10
+ for (const [sheetName, rows] of Object.entries(sheets)) {
11
+ XLSX.utils.book_append_sheet(
12
+ workbook,
13
+ XLSX.utils.aoa_to_sheet(rows),
14
+ sheetName,
15
+ );
16
+ }
17
+ const buffer = new Uint8Array(
18
+ XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
19
+ );
20
+ return new File([buffer], name, {
21
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
22
+ });
23
+ }
24
+
25
+ describe('extractXlsxFileToText', () => {
26
+ it('extracts non-empty sheets as CSV with headings', async () => {
27
+ const file = makeXlsxFile({
28
+ Data: [
29
+ ['name', 'value'],
30
+ ['a', 1],
31
+ ],
32
+ });
33
+
34
+ const text = await extractXlsxFileToText(file);
35
+ expect(text).toContain('## Sheet Data');
36
+ expect(text).toContain('name,value');
37
+ expect(text).toContain('a,1');
38
+ });
39
+
40
+ it('skips empty sheets', async () => {
41
+ const workbook = XLSX.utils.book_new();
42
+ XLSX.utils.book_append_sheet(
43
+ workbook,
44
+ XLSX.utils.aoa_to_sheet([]),
45
+ 'Empty',
46
+ );
47
+ XLSX.utils.book_append_sheet(
48
+ workbook,
49
+ XLSX.utils.aoa_to_sheet([['x']]),
50
+ 'Filled',
51
+ );
52
+ const buffer = new Uint8Array(
53
+ XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
54
+ );
55
+ const file = new File([buffer], 'mixed.xlsx', {
56
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
57
+ });
58
+
59
+ const text = await extractXlsxFileToText(file);
60
+ expect(text).not.toContain('## Sheet Empty');
61
+ expect(text).toContain('## Sheet Filled');
62
+ });
63
+
64
+ it('rejects files over the size limit', async () => {
65
+ const huge = new Uint8Array(10 * 1024 * 1024 + 1);
66
+ const file = new File([huge], 'huge.xlsx', {
67
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
68
+ });
69
+
70
+ await expect(extractXlsxFileToText(file)).rejects.toThrow(/too large/i);
71
+ });
72
+ });
@@ -0,0 +1,43 @@
1
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
2
+ const MAX_SHEETS = 20;
3
+ const MAX_CSV_CHARS_PER_SHEET = 500_000;
4
+
5
+ function truncateCsv(csv: string): string {
6
+ if (csv.length <= MAX_CSV_CHARS_PER_SHEET) return csv;
7
+ return `${csv.slice(0, MAX_CSV_CHARS_PER_SHEET).trimEnd()}…`;
8
+ }
9
+
10
+ /** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
11
+ export async function extractXlsxFileToText(file: File): Promise<string> {
12
+ const buffer = new Uint8Array(await file.arrayBuffer());
13
+ if (buffer.byteLength > MAX_FILE_BYTES) {
14
+ throw new Error(
15
+ `${file.name} is too large (max ${MAX_FILE_BYTES / (1024 * 1024)} MB)`,
16
+ );
17
+ }
18
+
19
+ const XLSX = await import('xlsx');
20
+ const workbook = XLSX.read(buffer, { type: 'array' });
21
+ const sheetNames = workbook.SheetNames.slice(0, MAX_SHEETS);
22
+ const sheetTexts: string[] = [];
23
+
24
+ for (const sheetName of sheetNames) {
25
+ const sheet = workbook.Sheets[sheetName];
26
+ if (!sheet) continue;
27
+
28
+ const csv = truncateCsv(
29
+ XLSX.utils.sheet_to_csv(sheet, { blankrows: false }).trim(),
30
+ );
31
+ if (csv) {
32
+ sheetTexts.push(`## Sheet ${sheetName}\n\n${csv}`);
33
+ }
34
+ }
35
+
36
+ if (workbook.SheetNames.length > MAX_SHEETS) {
37
+ sheetTexts.push(
38
+ `_(Only the first ${MAX_SHEETS} of ${workbook.SheetNames.length} sheets were included.)_`,
39
+ );
40
+ }
41
+
42
+ return sheetTexts.join('\n\n');
43
+ }
@@ -0,0 +1,53 @@
1
+ import { LinkIcon } from 'lucide-react';
2
+
3
+ import S from './InteractiveContent.styl';
4
+
5
+ type AutolinkUrlProps = {
6
+ href: string;
7
+ display: string;
8
+ target?: string;
9
+ rel?: string;
10
+ };
11
+
12
+ export function displayUrlForAutolink(display: string): string {
13
+ if (display.startsWith('https://')) return display.slice(8);
14
+ if (display.startsWith('http://')) return display.slice(7);
15
+ return display;
16
+ }
17
+
18
+ export function shouldRenderAutolinkPill(label: string, href: string): boolean {
19
+ const normalizedLabel = label.trim();
20
+ const normalizedHref = href.trim();
21
+ if (!normalizedLabel || !normalizedHref) return false;
22
+
23
+ const labelAsHref = normalizeWwwToHttps(normalizedLabel);
24
+ if (labelAsHref === normalizedHref) return true;
25
+
26
+ return (
27
+ /^https?:\/\//i.test(normalizedLabel) ||
28
+ /^www\./i.test(normalizedLabel) ||
29
+ normalizedLabel === displayUrlForAutolink(normalizedHref)
30
+ );
31
+ }
32
+
33
+ const normalizeWwwToHttps = (raw: string): string => {
34
+ const t = raw.trim();
35
+ if (/^www\./i.test(t)) return `https://${t}`;
36
+ return t;
37
+ };
38
+
39
+ export function AutolinkUrl({ href, display, target, rel }: AutolinkUrlProps) {
40
+ return (
41
+ <a href={href} target={target} rel={rel} className={S.autolinkUrl}>
42
+ <LinkIcon size={12} aria-hidden className={S.autolinkUrlIcon} />
43
+ <span
44
+ className={S.autolinkUrlLabel}
45
+ title={displayUrlForAutolink(display)}
46
+ >
47
+ {displayUrlForAutolink(display)}
48
+ </span>
49
+ </a>
50
+ );
51
+ }
52
+
53
+ export { S as autolinkStyles };
@@ -1,20 +1,71 @@
1
1
  .root
2
+ display inline-block
3
+ max-width 100%
4
+ min-width 0
5
+ box-sizing border-box
6
+ overflow-wrap anywhere
7
+ word-break break-word
8
+
2
9
  strong
3
10
  font-weight 600
4
11
 
5
12
  em
6
13
  font-style italic
7
14
 
8
- a
9
- color var(--sb-green-600)
10
- text-decoration underline
11
- text-underline-offset 2px
15
+ .textLink
16
+ color var(--sb-green-600)
17
+ text-decoration underline
18
+ text-underline-offset 2px
19
+
20
+ &:hover
21
+ color var(--sb-green-700)
22
+
23
+ :global(.dark) &
24
+ color var(--sb-green-400)
12
25
 
13
26
  &:hover
14
- color var(--sb-green-700)
27
+ color var(--sb-green-300)
28
+
29
+ .autolinkUrl
30
+ position relative
31
+ display inline-flex
32
+ align-items center
33
+ gap 6px
34
+
35
+ max-width 100%
36
+ box-sizing border-box
37
+ vertical-align middle
38
+
39
+ padding 2px 8px 2px 6px
40
+ margin 0 1px
41
+
42
+ border-radius var(--p-2)
43
+
44
+ color var(--link-color)
45
+ font-size var(--text-xs)
46
+ line-height 1.4
47
+ text-decoration none
48
+
49
+ &::before
50
+ position absolute
51
+ top 0
52
+ left 0
53
+ content ''
54
+ display inline-block
55
+ width 100%
56
+ height 100%
57
+ background-color var(--link-color)
58
+ border-radius inherit
59
+ opacity .1
60
+
61
+ &:hover::before
62
+ opacity .2
15
63
 
16
- :global(.dark) &
17
- color var(--sb-green-400)
64
+ .autolinkUrlIcon
65
+ flex-shrink 0
18
66
 
19
- &:hover
20
- color var(--sb-green-300)
67
+ .autolinkUrlLabel
68
+ min-width 0
69
+ overflow hidden
70
+ text-overflow ellipsis
71
+ white-space nowrap
@@ -1,7 +1,11 @@
1
1
  // This file is automatically generated.
2
2
  // Please do not change this file!
3
3
  interface CssExports {
4
+ 'autolinkUrl': string;
5
+ 'autolinkUrlIcon': string;
6
+ 'autolinkUrlLabel': string;
4
7
  'root': string;
8
+ 'textLink': string;
5
9
  }
6
10
  export const cssExports: CssExports;
7
11
  export default cssExports;
@@ -1 +1,2 @@
1
1
  export { InteractiveContent } from './InteractiveContent';
2
+ export { AutolinkUrl, displayUrlForAutolink } from './AutolinkUrl';
@@ -97,16 +97,16 @@ export default function ChatAttachmentsDropzonePage() {
97
97
  <AppPageHeader
98
98
  breadcrumbs={[{ label: 'Chat' }, { label: 'Attachments dropzone' }]}
99
99
  title="Chat — attachments dropzone"
100
- subheader="Drop text files onto the chat shell; they appear on the prompt until you send."
100
+ subheader="Drop text, Office, or PDF files onto the chat shell; they appear on the prompt until you send."
101
101
  actions={<DocsHeaderActions />}
102
102
  />
103
103
  <PageContentSection>
104
104
  <p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
105
- Drop a <code>.txt</code>, <code>.csv</code>, <code>.md</code>, or{' '}
106
- <code>.pdf</code> file anywhere on the chat panel. The file shows
107
- above the composer; press send to post it. Only MIME types from the
108
- text allowlist are accepted; PDF parsing is optional via{' '}
109
- <code>allowPdfAttachments</code>.
105
+ Drop a <code>.txt</code>, <code>.csv</code>, <code>.md</code>,{' '}
106
+ <code>.docx</code>, <code>.xlsx</code>, or <code>.pdf</code> file
107
+ anywhere on the chat panel. The file shows above the composer; press
108
+ send to post it. Office and PDF parsers load on demand when you attach
109
+ those types; PDF also requires <code>allowPdfAttachments</code>.
110
110
  </p>
111
111
  <ChatChrome
112
112
  showResizeHandle={false}
@@ -133,25 +133,19 @@ export default function ChatAttachmentsDropzonePage() {
133
133
  effectiveScopeId="docs-chat-attachments-dropzone"
134
134
  onPromptSubmit={onPromptSubmit}
135
135
  onChatDeleted={() => {}}
136
- allowedAttachments={[
137
- 'text/plain',
138
- '.txt',
139
- 'text/csv',
140
- '.csv',
141
- 'text/markdown',
142
- '.md',
143
- 'application/json',
144
- '.json',
145
- ]}
136
+ allowedAttachments={TEXT_ATTACHMENT_ACCEPT_PARTS}
146
137
  allowPdfAttachments
147
138
  emptyState={{
148
- title: 'Drop a text file or PDF',
139
+ title: 'Drop a text, Office, or PDF file',
149
140
  description:
150
- 'Drag a file onto this panel, review it above the composer, then send.',
141
+ 'Drag a file onto this panel, review it above the composer, then send. DOCX and XLSX are parsed in the browser.',
151
142
  additionalContent: (
152
143
  <p style={{ fontSize: 13, opacity: 0.85 }}>
153
- Base allowlist includes{' '}
154
- {TEXT_ATTACHMENT_ACCEPT_PARTS.slice(0, 6).join(', ')}
144
+ Accepted types include <code>.txt</code>, <code>.csv</code>,{' '}
145
+ <code>.md</code>, <code>.json</code>, <code>.docx</code>,{' '}
146
+ <code>.xlsx</code>, and more (
147
+ {TEXT_ATTACHMENT_ACCEPT_PARTS.length} entries in the text
148
+ allowlist).
155
149
  </p>
156
150
  ),
157
151
  }}
@@ -4,7 +4,8 @@ import { PageContentSection } from '#uilib/components/ui/Page';
4
4
  import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
5
5
  import { DocsHeaderActions } from '../docsHeaderActions';
6
6
 
7
- const SAMPLE = 'Use **bold** and *italic* in formatted text.';
7
+ const SAMPLE =
8
+ 'Use **bold** and *italic* in formatted text. See https://www.theguardian.com/business/ng-interactive/2026/may/14/us-stock-market-war-inflation-tariffs-trump for context.';
8
9
 
9
10
  export default function InteractiveContentPage() {
10
11
  return (