@sybilion/uilib 1.3.80 → 1.3.82

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 (36) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +6 -4
  2. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.styl.js +2 -2
  3. package/dist/esm/components/ui/Chat/ChatPresets/ChatPresets.js +2 -2
  4. package/dist/esm/components/ui/Chat/ChatPresets/ChatPresets.styl.js +2 -2
  5. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +2 -2
  6. package/dist/esm/contexts/chat-context.js +13 -20
  7. package/dist/esm/contexts/chatPersistence.js +67 -0
  8. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.d.ts +1 -1
  9. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.types.d.ts +6 -1
  10. package/dist/esm/types/src/components/ui/Chat/ChatChrome/index.d.ts +1 -1
  11. package/dist/esm/types/src/components/ui/Chat/ChatPresets/ChatPresets.d.ts +3 -1
  12. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -1
  13. package/dist/esm/types/src/contexts/chatPersistence.d.ts +5 -0
  14. package/dist/esm/types/src/contexts/chatPersistence.test.d.ts +1 -0
  15. package/dist/esm/types/tests/mocks/homecodeUiMock.d.ts +6 -0
  16. package/package.json +1 -1
  17. package/src/components/ui/Chat/ChatChrome/ChatChrome.styl +22 -4
  18. package/src/components/ui/Chat/ChatChrome/ChatChrome.styl.d.ts +4 -0
  19. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +18 -9
  20. package/src/components/ui/Chat/ChatChrome/ChatChrome.types.ts +11 -1
  21. package/src/components/ui/Chat/ChatChrome/index.ts +1 -0
  22. package/src/components/ui/Chat/ChatPresets/ChatPresets.styl +2 -1
  23. package/src/components/ui/Chat/ChatPresets/ChatPresets.styl.d.ts +1 -0
  24. package/src/components/ui/Chat/ChatPresets/ChatPresets.tsx +10 -1
  25. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +6 -2
  26. package/src/components/ui/Chat/index.ts +1 -0
  27. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts +3 -0
  28. package/src/contexts/chat-context.tsx +14 -20
  29. package/src/contexts/chatPersistence.test.ts +142 -0
  30. package/src/contexts/chatPersistence.ts +79 -0
  31. package/src/docs/pages/ChartAreaInteractivePage.tsx +7 -8
  32. package/src/docs/pages/ChatAttachmentsDropzonePage.tsx +1 -0
  33. package/src/docs/pages/ChatComposerPrefillPage.tsx +1 -0
  34. package/src/docs/pages/ChatPage.tsx +1 -1
  35. package/src/docs/pages/ChatSlashCommandsPage.tsx +1 -0
  36. package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +1 -0
@@ -15,7 +15,7 @@ import { filterToTextAttachments, isAttachmentsDropzoneEnabled, buildAcceptAttr
15
15
  import { extractChatAttachmentItems } from '../chatAttachmentExtract.js';
16
16
  import S from './ChatChrome.styl.js';
17
17
 
18
- function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPresets, messages, onQuickReply, suppressedQuickReplyKeys, isLoading, loadingLabel, scriptContinueLabel, onScriptContinue, renderMessageChart, renderSystemMessage, showSyntheticBranchButtons, unusedBranchKeys, showInlinePresets, isLastMessageFromUser, scrollRef, effectiveScopeId, onPromptSubmit, onChatDeleted, onNewChat, promptPrefill, footerClassName, emptyState, allowedAttachments, allowPdfAttachments = false, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, promptPlaceholder, hideChatSelector = false, }) {
18
+ function ChatChrome({ variant = 'default', showResizeHandle, resizeHandle, onClose, isEmpty, renderPresets, messages, onQuickReply, suppressedQuickReplyKeys, isLoading, loadingLabel, scriptContinueLabel, onScriptContinue, renderMessageChart, renderSystemMessage, showSyntheticBranchButtons, unusedBranchKeys, showInlinePresets, isLastMessageFromUser, scrollRef, effectiveScopeId, onPromptSubmit, onChatDeleted, onNewChat, promptPrefill, footerClassName, emptyState, allowedAttachments, allowPdfAttachments = false, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, promptPlaceholder, hideChatSelector = false, }) {
19
19
  const filteredAllowedAttachments = useMemo(() => filterToTextAttachments(allowedAttachments), [allowedAttachments]);
20
20
  const attachmentsDropzoneEnabled = isAttachmentsDropzoneEnabled(allowedAttachments, allowPdfAttachments);
21
21
  const attachmentAccept = useMemo(() => buildAcceptAttr(filteredAllowedAttachments, allowPdfAttachments), [filteredAllowedAttachments, allowPdfAttachments]);
@@ -76,7 +76,8 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
76
76
  showInlinePresets,
77
77
  showSyntheticBranchButtons,
78
78
  ]);
79
- 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: promptDisabled, className: S.attachmentDropzone, onFiles: handleAttachmentFiles })) : null, jsxs(Chat, { isEmpty: isEmpty, scopeId: effectiveScopeId, onChatDeleted: onChatDeleted, onNewChat: onNewChat, hideChatSelector: hideChatSelector, children: [isEmpty ? (jsx("div", { className: S.emptyBody, children: jsx(Chat.EmptyState, { ...emptyState }) })) : (jsx("div", { className: S.scrollWrapper, children: jsxs(Scroll, { y: true, yScrollbarClassName: S.scrollbar, className: S.scroll, innerClassName: S.scrollInner, offset: { y: { before: 56, after: 24 } }, fadeSize: "m", autoHide: true, ref: scrollRef, children: [messages.map((msg, index, arr) => {
79
+ const isClean = variant === 'clean';
80
+ return (jsxs("div", { className: cn(S.root, isClean && S.rootClean), 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: promptDisabled, className: S.attachmentDropzone, onFiles: handleAttachmentFiles })) : null, jsxs(Chat, { className: isClean ? S.chatClean : undefined, isEmpty: isEmpty, scopeId: effectiveScopeId, onChatDeleted: onChatDeleted, onNewChat: onNewChat, hideChatSelector: hideChatSelector, children: [isEmpty ? (jsx("div", { className: S.emptyBody, children: jsx(Chat.EmptyState, { ...emptyState }) })) : (jsx("div", { className: S.scrollWrapper, children: jsxs(Scroll, { y: true, yScrollbarClassName: S.scrollbar, className: S.scroll, innerClassName: S.scrollInner, offset: { y: { before: 56, after: 24 } }, fadeSize: "m", autoHide: true, ref: scrollRef, children: [messages.map((msg, index, arr) => {
80
81
  const isLast = index === arr.length - 1;
81
82
  return (jsx(Chat.Message, { message: msg, role: msg.role, text: msg.text, inProgress: msg.inProgress, userTextFileAttachments: msg.userTextFileAttachments, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, quickReplyHidden: Boolean(loadingLabel), isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
82
83
  ? { label: scriptContinueLabel }
@@ -87,9 +88,10 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
87
88
  const label = displayLabelForBranchKeyFromMessages(key, messages) ??
88
89
  humanizeBranchKey(key);
89
90
  return (jsx("span", { className: S.branchBtnWrap, children: jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: isLoading, onClick: () => onQuickReply(key, label), children: [jsx(PaperPlaneRightIcon, {}), label] }) }, key));
90
- }) })) : null, showInlinePresets && renderPresets('inline'), isLoading &&
91
+ }) })) : null, showInlinePresets &&
92
+ renderPresets('inline', { variant }), isLoading &&
91
93
  !hasInProgressSystemMessage &&
92
- (isLastMessageFromUser || loadingLabel) && (jsx(TextShimmer, { duration: 1, spread: 5, className: S.loader, children: loadingLabel ?? 'Thinking...' }))] }) })), isEmpty ? (jsx("div", { className: S.fixedPresets, children: renderPresets('fixed') })) : null, 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: promptDisabled, attachments: pendingAttachments, onRemoveAttachment: handleRemoveAttachment, prefillMessage: promptPrefill ?? undefined, placeholder: promptPlaceholder, slashCommandItems: slashCommandItems, onSlashItemCommand: onSlashItemCommand, attachmentAccept: attachmentsDropzoneEnabled ? attachmentAccept : undefined, onAttachmentFiles: attachmentsDropzoneEnabled ? handleAttachmentFiles : undefined })] })] })] })] }));
94
+ (isLastMessageFromUser || loadingLabel) && (jsx(TextShimmer, { duration: 1, spread: 5, className: S.loader, children: loadingLabel ?? 'Thinking...' }))] }) })), isEmpty ? (jsx("div", { className: S.fixedPresets, children: renderPresets('fixed', { variant }) })) : null, isEmpty ? (jsx("div", { className: S.notice, children: "Forecast Assistant can make mistakes." })) : null, jsx("div", { className: cn(S.footer, isClean && S.footerClean, footerClassName), children: jsx(Chat.Prompt, { className: isClean ? S.promptClean : undefined, onSubmit: handlePromptSubmitWithAttachments, disabled: promptDisabled, attachments: pendingAttachments, onRemoveAttachment: handleRemoveAttachment, prefillMessage: promptPrefill ?? undefined, placeholder: promptPlaceholder, slashCommandItems: slashCommandItems, onSlashItemCommand: onSlashItemCommand, attachmentAccept: attachmentsDropzoneEnabled ? attachmentAccept : undefined, onAttachmentFiles: attachmentsDropzoneEnabled ? handleAttachmentFiles : undefined }) })] })] })] }));
93
95
  }
94
96
 
95
97
  export { ChatChrome };
@@ -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)}}.ChatChrome_root__oh4Ay{border-radius:var(--p-4);display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;position:relative}.ChatChrome_chatResizeHandle__epfiT{background-color:var(--page-color);border-radius:2.5px;height:calc(100vh + 200px);left:0;opacity:0;position:absolute;right:auto;top:-200px;touch-action:none;transition:opacity .15s ease-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:5px;z-index:30}.ChatChrome_chatResizeHandle__epfiT:before{left:0;right:auto}.ChatChrome_panelHeader__Hkfit{align-items:center;display:flex;flex-shrink:0;justify-content:flex-end;min-height:70px;padding:var(--p-2) var(--p-3);position:absolute;width:100%;z-index:100}.ChatChrome_panelClose__DbKxz{flex-shrink:0}.ChatChrome_content__5qFEi{display:flex;flex:1;flex-direction:column;min-height:0;position:relative}.ChatChrome_attachmentDropzone__OC8UI{inset:0;position:absolute;z-index:200}.ChatChrome_scrollWrapper__m4HMu{flex:1;min-height:0;position:relative}.ChatChrome_scroll__oCxoJ{align-items:flex-end;height:100%;max-height:100%;max-width:100%;padding-bottom:var(--p-2);position:absolute;width:100%;z-index:3}.ChatChrome_scrollbar__Hu0aG{right:0!important}.ChatChrome_scrollInner__K9hIy{min-height:100%;padding-top:var(--p-10)}.ChatChrome_emptyBody__f2NE8{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.ChatChrome_emptyBody__f2NE8>*{flex:1;min-height:0;overflow:auto}.ChatChrome_footer__a5Bpp{backdrop-filter:blur(30px);background-color:var(--background-alpha-800);border-top:1px solid var(--border);box-shadow:0 8px 24px 0 var(--background);display:flex;flex-direction:column;flex-shrink:0;position:relative;width:100%;z-index:50}.ChatChrome_fixedPresets__bONhR{flex-shrink:0;position:relative;width:100%;z-index:10}.ChatChrome_notice__JACIw{color:var(--muted-foreground);font-size:var(--text-xs);left:0;margin-bottom:var(--p-1);pointer-events:none;position:absolute;right:0;text-align:center;top:calc(var(--p-7)*-1)}@media (max-width:768px){.ChatChrome_notice__JACIw{font-size:10px}}.ChatChrome_loader__9-lnf{color:var(--muted-foreground);margin:var(--p-2) var(--p-6) var(--p-10)}.ChatChrome_branchRow__NMDNv{display:flex;flex-wrap:wrap;gap:8px;margin-top:var(--p-6);padding:0 var(--p-6);width:100%}.ChatChrome_branchBtnWrap__aOSVP{display:inline-flex;vertical-align:middle}";
4
- var S = {"root":"ChatChrome_root__oh4Ay","chatResizeHandle":"ChatChrome_chatResizeHandle__epfiT","panelHeader":"ChatChrome_panelHeader__Hkfit","panelClose":"ChatChrome_panelClose__DbKxz","content":"ChatChrome_content__5qFEi","attachmentDropzone":"ChatChrome_attachmentDropzone__OC8UI","scrollWrapper":"ChatChrome_scrollWrapper__m4HMu","scroll":"ChatChrome_scroll__oCxoJ","scrollbar":"ChatChrome_scrollbar__Hu0aG","scrollInner":"ChatChrome_scrollInner__K9hIy","emptyBody":"ChatChrome_emptyBody__f2NE8","footer":"ChatChrome_footer__a5Bpp","fixedPresets":"ChatChrome_fixedPresets__bONhR","notice":"ChatChrome_notice__JACIw","loader":"ChatChrome_loader__9-lnf","branchRow":"ChatChrome_branchRow__NMDNv","branchBtnWrap":"ChatChrome_branchBtnWrap__aOSVP"};
3
+ var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.ChatChrome_root__oh4Ay{border-radius:var(--p-4);display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;position:relative}.ChatChrome_rootClean__WXM91{border-radius:0}.ChatChrome_chatResizeHandle__epfiT{background-color:var(--page-color);border-radius:2.5px;height:calc(100vh + 200px);left:0;opacity:0;position:absolute;right:auto;top:-200px;touch-action:none;transition:opacity .15s ease-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:5px;z-index:30}.ChatChrome_chatResizeHandle__epfiT:before{left:0;right:auto}.ChatChrome_panelHeader__Hkfit{align-items:center;display:flex;flex-shrink:0;justify-content:flex-end;min-height:70px;padding:var(--p-2) var(--p-3);position:absolute;width:100%;z-index:100}.ChatChrome_panelClose__DbKxz{flex-shrink:0}.ChatChrome_content__5qFEi{display:flex;flex:1;flex-direction:column;min-height:0;position:relative}.ChatChrome_chatClean__hM9yS{background-color:transparent}.ChatChrome_attachmentDropzone__OC8UI{inset:0;position:absolute;z-index:200}.ChatChrome_scrollWrapper__m4HMu{flex:1;min-height:0;position:relative}.ChatChrome_scroll__oCxoJ{align-items:flex-end;height:100%;max-height:100%;max-width:100%;padding-bottom:var(--p-2);position:absolute;width:100%;z-index:3}.ChatChrome_scrollbar__Hu0aG{right:0!important}.ChatChrome_scrollInner__K9hIy{min-height:100%;padding-top:var(--p-10)}.ChatChrome_emptyBody__f2NE8{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.ChatChrome_emptyBody__f2NE8>*{flex:1;min-height:0;overflow:auto}.ChatChrome_footer__a5Bpp{backdrop-filter:blur(30px);background-color:var(--background-alpha-800);border-top:1px solid var(--border);box-shadow:0 8px 24px 0 var(--background);display:flex;flex-direction:column;flex-shrink:0;position:relative;width:100%;z-index:50}.ChatChrome_footerClean__xRDdB{backdrop-filter:none;background-color:transparent;border-top:none;border:1px solid var(--border);border-radius:var(--p-4);box-shadow:none}.ChatChrome_promptClean__Hv43Z{background-color:var(--background);border-radius:inherit}.ChatChrome_fixedPresets__bONhR,.ChatChrome_notice__JACIw{flex-shrink:0;position:relative;width:100%;z-index:10}.ChatChrome_notice__JACIw{color:var(--muted-foreground);font-size:var(--text-xs);margin-bottom:var(--p-1);pointer-events:none;text-align:center}@media (max-width:768px){.ChatChrome_notice__JACIw{font-size:10px}}.ChatChrome_loader__9-lnf{color:var(--muted-foreground);margin:var(--p-2) var(--p-6) var(--p-10)}.ChatChrome_branchRow__NMDNv{display:flex;flex-wrap:wrap;gap:8px;margin-top:var(--p-6);padding:0 var(--p-6);width:100%}.ChatChrome_branchBtnWrap__aOSVP{display:inline-flex;vertical-align:middle}";
4
+ var S = {"root":"ChatChrome_root__oh4Ay","rootClean":"ChatChrome_rootClean__WXM91","chatResizeHandle":"ChatChrome_chatResizeHandle__epfiT","panelHeader":"ChatChrome_panelHeader__Hkfit","panelClose":"ChatChrome_panelClose__DbKxz","content":"ChatChrome_content__5qFEi","chatClean":"ChatChrome_chatClean__hM9yS","attachmentDropzone":"ChatChrome_attachmentDropzone__OC8UI","scrollWrapper":"ChatChrome_scrollWrapper__m4HMu","scroll":"ChatChrome_scroll__oCxoJ","scrollbar":"ChatChrome_scrollbar__Hu0aG","scrollInner":"ChatChrome_scrollInner__K9hIy","emptyBody":"ChatChrome_emptyBody__f2NE8","footer":"ChatChrome_footer__a5Bpp","footerClean":"ChatChrome_footerClean__xRDdB","promptClean":"ChatChrome_promptClean__Hv43Z","fixedPresets":"ChatChrome_fixedPresets__bONhR","notice":"ChatChrome_notice__JACIw","loader":"ChatChrome_loader__9-lnf","branchRow":"ChatChrome_branchRow__NMDNv","branchBtnWrap":"ChatChrome_branchBtnWrap__aOSVP"};
5
5
  styleInject(css_248z);
6
6
 
7
7
  export { S as default };
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
4
4
  import { Button } from '../../Button/Button.js';
5
5
  import S from './ChatPresets.styl.js';
6
6
 
7
- function ChatPresets({ chatId, items, usedItemIds = [], layout = 'fixed', onSelect, onItemUsed, }) {
7
+ function ChatPresets({ chatId, items, usedItemIds = [], layout = 'fixed', variant = 'default', onSelect, onItemUsed, }) {
8
8
  const [localUsedItems, setLocalUsedItems] = useState([]);
9
9
  useEffect(() => {
10
10
  setLocalUsedItems([]);
@@ -13,7 +13,7 @@ function ChatPresets({ chatId, items, usedItemIds = [], layout = 'fixed', onSele
13
13
  const availableItems = items.filter(item => !usedItems.includes(item.id));
14
14
  if (availableItems.length === 0)
15
15
  return null;
16
- return (jsx("div", { className: layout === 'inline' ? S.inlineRoot : S.root, children: jsx("div", { className: cn(S.inner, layout === 'inline' && S.innerInline), children: availableItems.map(preset => (jsx(Button, { type: "button", variant: "outline", size: "sm", className: cn(S.item), onClick: () => {
16
+ return (jsx("div", { className: layout === 'inline' ? S.inlineRoot : S.root, children: jsx("div", { className: cn(S.inner, layout === 'inline' && S.innerInline, variant === 'clean' && S.innerClean), children: availableItems.map(preset => (jsx(Button, { type: "button", variant: "outline", size: "sm", className: cn(S.item), onClick: () => {
17
17
  onSelect?.(preset);
18
18
  onItemUsed?.(preset.id);
19
19
  setLocalUsedItems(prev => [...prev, preset.id]);
@@ -1,7 +1,7 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".ChatPresets_root__Cj42o{flex-shrink:0;position:relative;width:100%}.ChatPresets_inlineRoot__WXVnu{margin-top:var(--p-6);position:relative;width:100%}.ChatPresets_inner__h14-q{background-color:var(--background);display:flex;flex-wrap:wrap;gap:8px;min-width:0;padding:var(--p-2) var(--p-6) var(--p-3)}.ChatPresets_innerInline__iPM2b{background-color:transparent}.ChatPresets_item__LfX5b{flex:0 1 auto;font-size:var(--text-xs);height:auto;line-height:1.4;max-width:min(300px,100%);min-height:auto;min-width:0;overflow-wrap:anywhere;padding:var(--p-3);text-align:left;white-space:break-spaces!important}";
4
- var S = {"root":"ChatPresets_root__Cj42o","inlineRoot":"ChatPresets_inlineRoot__WXVnu","inner":"ChatPresets_inner__h14-q","innerInline":"ChatPresets_innerInline__iPM2b","item":"ChatPresets_item__LfX5b"};
3
+ var css_248z = ".ChatPresets_root__Cj42o{flex-shrink:0;position:relative;width:100%}.ChatPresets_inlineRoot__WXVnu{margin-top:var(--p-6);position:relative;width:100%}.ChatPresets_inner__h14-q{background-color:var(--background);display:flex;flex-wrap:wrap;gap:8px;min-width:0;padding:var(--p-2) var(--p-6) var(--p-3)}.ChatPresets_innerClean__U-sL0,.ChatPresets_innerInline__iPM2b{background-color:transparent}.ChatPresets_item__LfX5b{flex:0 1 auto;font-size:var(--text-xs);height:auto;line-height:1.4;max-width:min(300px,100%);min-height:auto;min-width:0;overflow-wrap:anywhere;padding:var(--p-3);text-align:left;white-space:break-spaces!important}";
4
+ var S = {"root":"ChatPresets_root__Cj42o","inlineRoot":"ChatPresets_inlineRoot__WXVnu","inner":"ChatPresets_inner__h14-q","innerInline":"ChatPresets_innerInline__iPM2b","innerClean":"ChatPresets_innerClean__U-sL0","item":"ChatPresets_item__LfX5b"};
5
5
  styleInject(css_248z);
6
6
 
7
7
  export { S as default };
@@ -813,10 +813,10 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
813
813
  sidebarWidthPx,
814
814
  chatWidthPx,
815
815
  ]);
816
- const renderPresets = (layout = 'fixed') => {
816
+ const renderPresets = (layout = 'fixed', options) => {
817
817
  if (!presetsWithFreeform?.length)
818
818
  return null;
819
- return (jsx(Chat.Presets, { chatId: currentChatId ?? '', items: presetsWithFreeform, layout: layout, usedItemIds: usedPresetIds, onSelect: preset => {
819
+ return (jsx(Chat.Presets, { chatId: currentChatId ?? '', items: presetsWithFreeform, layout: layout, variant: options?.variant, usedItemIds: usedPresetIds, onSelect: preset => {
820
820
  void submitPreset(preset);
821
821
  } }));
822
822
  };
@@ -4,6 +4,7 @@ import { MessageRole } from '../components/ui/Chat/Chat.types.js';
4
4
  import { normalizeUserTextFileAttachments } from '../components/ui/Chat/buildChatSendMessagePayload.js';
5
5
  import { stripJsonDashboardFences } from '../lib/dashboard-spec/stripJsonDashboardFences.js';
6
6
  import { LS } from '@homecode/ui';
7
+ import { persistChatsToLS, safeLsSet } from './chatPersistence.js';
7
8
 
8
9
  const CHATS_PREFIX = 'chats-';
9
10
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
@@ -77,7 +78,7 @@ function addScopeIdToRegistry(scopeId) {
77
78
  const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
78
79
  const registry = Array.isArray(raw) ? [...raw] : [];
79
80
  if (!registry.includes(scopeId)) {
80
- LS.set(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
81
+ safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
81
82
  }
82
83
  }
83
84
  /** Shallow-clone messages for seeding another session; drops in-progress rows. */
@@ -155,25 +156,23 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
155
156
  setChats(prev => {
156
157
  const currentChats = prev[scopeId] ?? [];
157
158
  const updatedChats = [newChat, ...currentChats];
158
- const chatsKey = getChatsKey(scopeId);
159
- LS.set(chatsKey, updatedChats);
159
+ persistChatsToLS(scopeId, updatedChats);
160
160
  return {
161
161
  ...prev,
162
162
  [scopeId]: updatedChats,
163
163
  };
164
164
  });
165
165
  setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
166
- LS.set(getCurrentChatIdKey(scopeId), sessionId);
166
+ safeLsSet(getCurrentChatIdKey(scopeId), sessionId);
167
167
  return sessionId;
168
168
  }, [userSwitchKey]);
169
169
  const setCurrentChatId = useCallback((currScopeId, sessionId) => {
170
170
  if (!sessionId)
171
171
  return;
172
172
  setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
173
- LS.set(getCurrentChatIdKey(currScopeId), sessionId);
173
+ safeLsSet(getCurrentChatIdKey(currScopeId), sessionId);
174
174
  }, []);
175
175
  const deleteChat = useCallback((scopeId, sessionId) => {
176
- const chatsKey = getChatsKey(scopeId);
177
176
  const currentKey = getCurrentChatIdKey(scopeId);
178
177
  setChats(prev => {
179
178
  const scopeChats = prev[scopeId] ?? [];
@@ -182,7 +181,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
182
181
  return prev;
183
182
  }
184
183
  const updatedChats = scopeChats.filter(c => c.session_id !== sessionId);
185
- LS.set(chatsKey, updatedChats);
184
+ persistChatsToLS(scopeId, updatedChats);
186
185
  setCurrentChatIdState(prevCurr => {
187
186
  if (prevCurr[scopeId] !== sessionId) {
188
187
  return prevCurr;
@@ -191,7 +190,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
191
190
  ? scopeChats[deletedIndex - 1].session_id
192
191
  : (updatedChats[0]?.session_id ?? null);
193
192
  if (nextId) {
194
- LS.set(currentKey, nextId);
193
+ safeLsSet(currentKey, nextId);
195
194
  }
196
195
  else {
197
196
  LS.remove(currentKey);
@@ -228,8 +227,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
228
227
  }
229
228
  return chat;
230
229
  });
231
- const chatsKey = getChatsKey(scopeId);
232
- LS.set(chatsKey, updatedChats);
230
+ persistChatsToLS(scopeId, updatedChats);
233
231
  return { ...prev, [scopeId]: updatedChats };
234
232
  });
235
233
  return newMessage.id;
@@ -247,8 +245,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
247
245
  messages: chat.messages.filter(m => m.id !== messageId),
248
246
  };
249
247
  });
250
- const chatsKey = getChatsKey(scopeId);
251
- LS.set(chatsKey, updatedChats);
248
+ persistChatsToLS(scopeId, updatedChats);
252
249
  return { ...prev, [scopeId]: updatedChats };
253
250
  });
254
251
  }, [userSwitchKey]);
@@ -286,8 +283,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
286
283
  }),
287
284
  };
288
285
  });
289
- const chatsKey = getChatsKey(scopeId);
290
- LS.set(chatsKey, updatedChats);
286
+ persistChatsToLS(scopeId, updatedChats);
291
287
  return { ...prev, [scopeId]: updatedChats };
292
288
  });
293
289
  }, [userSwitchKey]);
@@ -306,8 +302,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
306
302
  return chat;
307
303
  return { ...chat, messages: cloned };
308
304
  });
309
- const chatsKey = getChatsKey(scopeId);
310
- LS.set(chatsKey, updatedChats);
305
+ persistChatsToLS(scopeId, updatedChats);
311
306
  return { ...prev, [scopeId]: updatedChats };
312
307
  });
313
308
  }, [userSwitchKey]);
@@ -324,8 +319,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
324
319
  meta: { ...chat.meta, ...patch },
325
320
  };
326
321
  });
327
- const chatsKey = getChatsKey(scopeId);
328
- LS.set(chatsKey, updatedChats);
322
+ persistChatsToLS(scopeId, updatedChats);
329
323
  return { ...prev, [scopeId]: updatedChats };
330
324
  });
331
325
  }, [userSwitchKey]);
@@ -362,8 +356,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
362
356
  const updatedChats = scopeChats.map(chat => chat.session_id === pendingChatSessionId
363
357
  ? { ...chat, session_id: data.session_id }
364
358
  : chat);
365
- const chatsKey = getChatsKey(scopeId);
366
- LS.set(chatsKey, updatedChats);
359
+ persistChatsToLS(scopeId, updatedChats);
367
360
  return { ...prev, [scopeId]: updatedChats };
368
361
  });
369
362
  setCurrentChatId(scopeId, data.session_id);
@@ -0,0 +1,67 @@
1
+ import { stripJsonDashboardFences } from '../lib/dashboard-spec/stripJsonDashboardFences.js';
2
+ import { LS } from '@homecode/ui';
3
+
4
+ /** Ephemeral scopes (e.g. /reports/new draft) — keep only the latest session in LS. */
5
+ const EPHEMERAL_SCOPE_MARKERS = ['__reports_new_draft__'];
6
+ function isEphemeralChatScope(scopeId) {
7
+ return EPHEMERAL_SCOPE_MARKERS.some(marker => scopeId.endsWith(marker));
8
+ }
9
+ function isQuotaExceededError(error) {
10
+ if (!(error instanceof DOMException))
11
+ return false;
12
+ return (error.name === 'QuotaExceededError' ||
13
+ error.code === 22 ||
14
+ error.code === 1014);
15
+ }
16
+ function stripAttachmentForPersistence(attachment) {
17
+ return {
18
+ displayName: attachment.displayName,
19
+ filename: attachment.filename,
20
+ content: '',
21
+ };
22
+ }
23
+ function stripMessageForPersistence(message) {
24
+ const text = stripJsonDashboardFences(message.text);
25
+ const next = { ...message, text };
26
+ if (message.inProgress) {
27
+ delete next.inProgress;
28
+ }
29
+ if (message.userTextFileAttachments?.length) {
30
+ next.userTextFileAttachments = message.userTextFileAttachments.map(stripAttachmentForPersistence);
31
+ }
32
+ return next;
33
+ }
34
+ function stripChatsForPersistence(chats) {
35
+ return chats.map(chat => ({
36
+ ...chat,
37
+ messages: chat.messages
38
+ .filter(message => !message.inProgress)
39
+ .map(stripMessageForPersistence),
40
+ }));
41
+ }
42
+ function chatsForScopePersistence(scopeId, chats) {
43
+ const scopeChats = isEphemeralChatScope(scopeId) ? chats.slice(0, 1) : chats;
44
+ return stripChatsForPersistence(scopeChats);
45
+ }
46
+ function persistChatsToLS(scopeId, chats) {
47
+ const chatsKey = `chats-${scopeId}`;
48
+ const payload = chatsForScopePersistence(scopeId, chats);
49
+ try {
50
+ LS.set(chatsKey, payload);
51
+ }
52
+ catch (error) {
53
+ if (!isQuotaExceededError(error))
54
+ throw error;
55
+ }
56
+ }
57
+ function safeLsSet(key, value) {
58
+ try {
59
+ LS.set(key, value);
60
+ }
61
+ catch (error) {
62
+ if (!isQuotaExceededError(error))
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ export { persistChatsToLS, safeLsSet, stripChatsForPersistence, stripMessageForPersistence };
@@ -1,2 +1,2 @@
1
1
  import type { ChatChromeProps } from './ChatChrome.types';
2
- export declare function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPresets, messages, onQuickReply, suppressedQuickReplyKeys, isLoading, loadingLabel, scriptContinueLabel, onScriptContinue, renderMessageChart, renderSystemMessage, showSyntheticBranchButtons, unusedBranchKeys, showInlinePresets, isLastMessageFromUser, scrollRef, effectiveScopeId, onPromptSubmit, onChatDeleted, onNewChat, promptPrefill, footerClassName, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, promptPlaceholder, hideChatSelector, }: ChatChromeProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatChrome({ variant, showResizeHandle, resizeHandle, onClose, isEmpty, renderPresets, messages, onQuickReply, suppressedQuickReplyKeys, isLoading, loadingLabel, scriptContinueLabel, onScriptContinue, renderMessageChart, renderSystemMessage, showSyntheticBranchButtons, unusedBranchKeys, showInlinePresets, isLastMessageFromUser, scrollRef, effectiveScopeId, onPromptSubmit, onChatDeleted, onNewChat, promptPrefill, footerClassName, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, promptPlaceholder, hideChatSelector, }: ChatChromeProps): import("react/jsx-runtime").JSX.Element;
@@ -12,12 +12,17 @@ export type ChatChromeResizeHandleConfig = {
12
12
  onDragComplete: (finalRawPx: number) => void;
13
13
  };
14
14
  export type { ChatAttachmentDropItem };
15
+ export type ChatChromeVariant = 'default' | 'clean';
16
+ export type ChatPresetsRenderOptions = {
17
+ variant?: ChatChromeVariant;
18
+ };
15
19
  export interface ChatChromeProps {
20
+ variant?: ChatChromeVariant;
16
21
  showResizeHandle: boolean;
17
22
  resizeHandle: ChatChromeResizeHandleConfig | undefined;
18
23
  onClose: (() => void) | undefined;
19
24
  isEmpty: boolean;
20
- renderPresets: (layout: ChatPresetsLayout) => React.ReactNode;
25
+ renderPresets: (layout: ChatPresetsLayout, options?: ChatPresetsRenderOptions) => React.ReactNode;
21
26
  /** Message list for the scroll area; use `[]` when empty. */
22
27
  messages: Message[];
23
28
  onQuickReply: (branchKey: string, displayLabel: string) => void;
@@ -1,2 +1,2 @@
1
1
  export { ChatChrome } from './ChatChrome';
2
- export type { ChatAttachmentDropItem, ChatChromeProps, ChatChromeResizeHandleConfig, } from './ChatChrome.types';
2
+ export type { ChatAttachmentDropItem, ChatChromeProps, ChatChromeResizeHandleConfig, ChatChromeVariant, } from './ChatChrome.types';
@@ -1,3 +1,4 @@
1
+ import type { ChatChromeVariant } from '../ChatChrome/ChatChrome.types';
1
2
  import type { ChatPreset } from '../Chat.types';
2
3
  export type ChatPresetsLayout = 'fixed' | 'inline';
3
4
  export interface ChatPresetsProps {
@@ -5,7 +6,8 @@ export interface ChatPresetsProps {
5
6
  items: ChatPreset[];
6
7
  usedItemIds?: string[];
7
8
  layout?: ChatPresetsLayout;
9
+ variant?: ChatChromeVariant;
8
10
  onSelect?: (preset: ChatPreset) => void;
9
11
  onItemUsed?: (id: string) => void;
10
12
  }
11
- export declare function ChatPresets({ chatId, items, usedItemIds, layout, onSelect, onItemUsed, }: ChatPresetsProps): import("react/jsx-runtime").JSX.Element;
13
+ export declare function ChatPresets({ chatId, items, usedItemIds, layout, variant, onSelect, onItemUsed, }: ChatPresetsProps): import("react/jsx-runtime").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  export { Chat } from './Chat';
2
2
  export { formatChatTranscript, usedPresetIdsFromMessages, } from './chat-preset-utils';
3
3
  export { ChatChrome } from './ChatChrome';
4
- export type { ChatChromeProps, ChatChromeResizeHandleConfig, } from './ChatChrome';
4
+ export type { ChatChromeProps, ChatChromeResizeHandleConfig, ChatChromeVariant, } from './ChatChrome';
5
5
  export { TEXT_ATTACHMENT_ACCEPT_PARTS, filterToTextAttachments, } from './chatAttachmentAccept';
6
6
  export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments, } from './buildChatSendMessagePayload';
7
7
  export { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
@@ -0,0 +1,5 @@
1
+ import type { Chat, Message } from '#uilib/components/ui/Chat/Chat.types';
2
+ export declare function stripMessageForPersistence(message: Message): Message;
3
+ export declare function stripChatsForPersistence(chats: Chat[]): Chat[];
4
+ export declare function persistChatsToLS(scopeId: string, chats: Chat[]): void;
5
+ export declare function safeLsSet(key: string, value: unknown): void;
@@ -0,0 +1,6 @@
1
+ declare const LS: {
2
+ get(key: string): any;
3
+ set(key: string, val: unknown): void;
4
+ remove(key: string): void;
5
+ };
6
+ export { LS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.80",
3
+ "version": "1.3.82",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -9,6 +9,9 @@
9
9
  overflow hidden
10
10
  border-radius var(--p-4)
11
11
 
12
+ .rootClean
13
+ border-radius 0
14
+
12
15
  .chatResizeHandle
13
16
  position: absolute;
14
17
  // top 0 !important
@@ -57,6 +60,9 @@
57
60
  flex-direction column
58
61
  position relative
59
62
 
63
+ .chatClean
64
+ background-color transparent
65
+
60
66
  .attachmentDropzone
61
67
  position absolute
62
68
  inset 0
@@ -111,6 +117,18 @@
111
117
  border-top 1px solid var(--border)
112
118
  box-shadow 0 8px 24px 0 var(--background)
113
119
 
120
+ .footerClean
121
+ backdrop-filter none
122
+ background-color transparent
123
+ border-top none
124
+ box-shadow none
125
+ border 1px solid var(--border)
126
+ border-radius var(--p-4)
127
+
128
+ .promptClean
129
+ background-color var(--background)
130
+ border-radius inherit
131
+
114
132
  .fixedPresets
115
133
  position relative
116
134
  z-index 10
@@ -118,10 +136,10 @@
118
136
  width 100%
119
137
 
120
138
  .notice
121
- position absolute
122
- top calc(-1 * var(--p-7))
123
- left 0
124
- right 0
139
+ position relative
140
+ z-index 10
141
+ flex-shrink 0
142
+ width 100%
125
143
  margin-bottom var(--p-1)
126
144
 
127
145
  font-size var(--text-xs)
@@ -4,16 +4,20 @@ interface CssExports {
4
4
  'attachmentDropzone': string;
5
5
  'branchBtnWrap': string;
6
6
  'branchRow': string;
7
+ 'chatClean': string;
7
8
  'chatResizeHandle': string;
8
9
  'content': string;
9
10
  'emptyBody': string;
10
11
  'fixedPresets': string;
11
12
  'footer': string;
13
+ 'footerClean': string;
12
14
  'loader': string;
13
15
  'notice': string;
14
16
  'panelClose': string;
15
17
  'panelHeader': string;
18
+ 'promptClean': string;
16
19
  'root': string;
20
+ 'rootClean': string;
17
21
  'scroll': string;
18
22
  'scrollInner': string;
19
23
  'scrollWrapper': string;
@@ -25,6 +25,7 @@ import S from './ChatChrome.styl';
25
25
  import type { ChatChromeProps } from './ChatChrome.types';
26
26
 
27
27
  export function ChatChrome({
28
+ variant = 'default',
28
29
  showResizeHandle,
29
30
  resizeHandle,
30
31
  onClose,
@@ -150,8 +151,10 @@ export function ChatChrome({
150
151
  showSyntheticBranchButtons,
151
152
  ]);
152
153
 
154
+ const isClean = variant === 'clean';
155
+
153
156
  return (
154
- <div className={S.root}>
157
+ <div className={cn(S.root, isClean && S.rootClean)}>
155
158
  {showResizeHandle && resizeHandle ? (
156
159
  <PanelResizeHandle
157
160
  edge="leading"
@@ -191,6 +194,7 @@ export function ChatChrome({
191
194
  />
192
195
  ) : null}
193
196
  <Chat
197
+ className={isClean ? S.chatClean : undefined}
194
198
  isEmpty={isEmpty}
195
199
  scopeId={effectiveScopeId}
196
200
  onChatDeleted={onChatDeleted}
@@ -268,7 +272,8 @@ export function ChatChrome({
268
272
  </div>
269
273
  ) : null}
270
274
 
271
- {showInlinePresets && renderPresets('inline')}
275
+ {showInlinePresets &&
276
+ renderPresets('inline', { variant })}
272
277
 
273
278
  {isLoading &&
274
279
  !hasInProgressSystemMessage &&
@@ -282,16 +287,20 @@ export function ChatChrome({
282
287
  )}
283
288
 
284
289
  {isEmpty ? (
285
- <div className={S.fixedPresets}>{renderPresets('fixed')}</div>
290
+ <div className={S.fixedPresets}>
291
+ {renderPresets('fixed', { variant })}
292
+ </div>
293
+ ) : null}
294
+
295
+ {isEmpty ? (
296
+ <div className={S.notice}>
297
+ Forecast Assistant can make mistakes.
298
+ </div>
286
299
  ) : null}
287
300
 
288
- <div className={cn(S.footer, footerClassName)}>
289
- {isEmpty ? (
290
- <div className={S.notice}>
291
- Forecast Assistant can make mistakes.
292
- </div>
293
- ) : null}
301
+ <div className={cn(S.footer, isClean && S.footerClean, footerClassName)}>
294
302
  <Chat.Prompt
303
+ className={isClean ? S.promptClean : undefined}
295
304
  onSubmit={handlePromptSubmitWithAttachments}
296
305
  disabled={promptDisabled}
297
306
  attachments={pendingAttachments}
@@ -22,12 +22,22 @@ export type ChatChromeResizeHandleConfig = {
22
22
 
23
23
  export type { ChatAttachmentDropItem };
24
24
 
25
+ export type ChatChromeVariant = 'default' | 'clean';
26
+
27
+ export type ChatPresetsRenderOptions = {
28
+ variant?: ChatChromeVariant;
29
+ };
30
+
25
31
  export interface ChatChromeProps {
32
+ variant?: ChatChromeVariant;
26
33
  showResizeHandle: boolean;
27
34
  resizeHandle: ChatChromeResizeHandleConfig | undefined;
28
35
  onClose: (() => void) | undefined;
29
36
  isEmpty: boolean;
30
- renderPresets: (layout: ChatPresetsLayout) => React.ReactNode;
37
+ renderPresets: (
38
+ layout: ChatPresetsLayout,
39
+ options?: ChatPresetsRenderOptions,
40
+ ) => React.ReactNode;
31
41
  /** Message list for the scroll area; use `[]` when empty. */
32
42
  messages: Message[];
33
43
  onQuickReply: (branchKey: string, displayLabel: string) => void;
@@ -3,4 +3,5 @@ export type {
3
3
  ChatAttachmentDropItem,
4
4
  ChatChromeProps,
5
5
  ChatChromeResizeHandleConfig,
6
+ ChatChromeVariant,
6
7
  } from './ChatChrome.types';
@@ -16,7 +16,8 @@
16
16
  padding var(--p-2) var(--p-6) var(--p-3)
17
17
  background-color var(--background)
18
18
 
19
- .innerInline
19
+ .innerInline,
20
+ .innerClean
20
21
  background-color transparent
21
22
 
22
23
  .item
@@ -3,6 +3,7 @@
3
3
  interface CssExports {
4
4
  'inlineRoot': string;
5
5
  'inner': string;
6
+ 'innerClean': string;
6
7
  'innerInline': string;
7
8
  'item': string;
8
9
  'root': string;
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
3
3
 
4
4
  import { Button } from '#uilib/components/ui/Button';
5
5
 
6
+ import type { ChatChromeVariant } from '../ChatChrome/ChatChrome.types';
6
7
  import type { ChatPreset } from '../Chat.types';
7
8
  import S from './ChatPresets.styl';
8
9
 
@@ -13,6 +14,7 @@ export interface ChatPresetsProps {
13
14
  items: ChatPreset[];
14
15
  usedItemIds?: string[];
15
16
  layout?: ChatPresetsLayout;
17
+ variant?: ChatChromeVariant;
16
18
  onSelect?: (preset: ChatPreset) => void;
17
19
  onItemUsed?: (id: string) => void;
18
20
  }
@@ -22,6 +24,7 @@ export function ChatPresets({
22
24
  items,
23
25
  usedItemIds = [],
24
26
  layout = 'fixed',
27
+ variant = 'default',
25
28
  onSelect,
26
29
  onItemUsed,
27
30
  }: ChatPresetsProps) {
@@ -38,7 +41,13 @@ export function ChatPresets({
38
41
 
39
42
  return (
40
43
  <div className={layout === 'inline' ? S.inlineRoot : S.root}>
41
- <div className={cn(S.inner, layout === 'inline' && S.innerInline)}>
44
+ <div
45
+ className={cn(
46
+ S.inner,
47
+ layout === 'inline' && S.innerInline,
48
+ variant === 'clean' && S.innerClean,
49
+ )}
50
+ >
42
51
  {availableItems.map(preset => (
43
52
  <Button
44
53
  key={preset.id}
@@ -51,7 +51,7 @@ import {
51
51
  } from '../../Sidebar/Sidebar';
52
52
  import { Chat } from '../Chat';
53
53
  import type { ChatChromeProps } from '../ChatChrome';
54
- import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
54
+ import type { ChatAttachmentDropItem, ChatPresetsRenderOptions } from '../ChatChrome/ChatChrome.types';
55
55
  import type { ChatEmptyStateConfig } from '../ChatEmptyState/ChatEmptyState.types';
56
56
  import type { ChatEmptyStateProps } from '../ChatEmptyState/ChatEmptyState.types';
57
57
  import {
@@ -1106,7 +1106,10 @@ export function useChatPanelChromeModel({
1106
1106
  chatWidthPx,
1107
1107
  ]);
1108
1108
 
1109
- const renderPresets = (layout: ChatPresetsLayout = 'fixed') => {
1109
+ const renderPresets = (
1110
+ layout: ChatPresetsLayout = 'fixed',
1111
+ options?: ChatPresetsRenderOptions,
1112
+ ) => {
1110
1113
  if (!presetsWithFreeform?.length) return null;
1111
1114
 
1112
1115
  return (
@@ -1114,6 +1117,7 @@ export function useChatPanelChromeModel({
1114
1117
  chatId={currentChatId ?? ''}
1115
1118
  items={presetsWithFreeform}
1116
1119
  layout={layout}
1120
+ variant={options?.variant}
1117
1121
  usedItemIds={usedPresetIds}
1118
1122
  onSelect={preset => {
1119
1123
  void submitPreset(preset);
@@ -7,6 +7,7 @@ export { ChatChrome } from './ChatChrome';
7
7
  export type {
8
8
  ChatChromeProps,
9
9
  ChatChromeResizeHandleConfig,
10
+ ChatChromeVariant,
10
11
  } from './ChatChrome';
11
12
  export {
12
13
  TEXT_ATTACHMENT_ACCEPT_PARTS,
@@ -262,6 +262,9 @@ describe('buildDriversComparisonChartData historical window floor', () => {
262
262
  };
263
263
 
264
264
  const datasetHistorical: ChartDataPoint[] = [
265
+ { date: '2014-07-01', historical: 97 },
266
+ { date: '2014-08-01', historical: 98 },
267
+ { date: '2014-09-01', historical: 99 },
265
268
  { date: '2014-10-01', historical: 100 },
266
269
  { date: '2014-11-01', historical: 101 },
267
270
  { date: '2014-12-01', historical: 102 },
@@ -21,6 +21,8 @@ import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDas
21
21
  import type { ChatResponse } from '#uilib/types/chat-api.types';
22
22
  import { LS } from '@homecode/ui';
23
23
 
24
+ import { persistChatsToLS, safeLsSet } from './chatPersistence';
25
+
24
26
  export type SendChatMessageFn = (
25
27
  message: string,
26
28
  targetChatId: string,
@@ -184,7 +186,7 @@ function addScopeIdToRegistry(scopeId: string) {
184
186
  const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
185
187
  const registry = Array.isArray(raw) ? [...raw] : [];
186
188
  if (!registry.includes(scopeId)) {
187
- LS.set(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
189
+ safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
188
190
  }
189
191
  }
190
192
 
@@ -300,8 +302,7 @@ export function ChatProvider({
300
302
  const currentChats = prev[scopeId] ?? [];
301
303
  const updatedChats = [newChat, ...currentChats];
302
304
 
303
- const chatsKey = getChatsKey(scopeId);
304
- LS.set(chatsKey, updatedChats);
305
+ persistChatsToLS(scopeId, updatedChats);
305
306
 
306
307
  return {
307
308
  ...prev,
@@ -309,7 +310,7 @@ export function ChatProvider({
309
310
  };
310
311
  });
311
312
  setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
312
- LS.set(getCurrentChatIdKey(scopeId), sessionId);
313
+ safeLsSet(getCurrentChatIdKey(scopeId), sessionId);
313
314
  return sessionId;
314
315
  },
315
316
  [userSwitchKey],
@@ -319,13 +320,12 @@ export function ChatProvider({
319
320
  (currScopeId: string, sessionId: string) => {
320
321
  if (!sessionId) return;
321
322
  setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
322
- LS.set(getCurrentChatIdKey(currScopeId), sessionId);
323
+ safeLsSet(getCurrentChatIdKey(currScopeId), sessionId);
323
324
  },
324
325
  [],
325
326
  );
326
327
 
327
328
  const deleteChat = useCallback((scopeId: string, sessionId: string) => {
328
- const chatsKey = getChatsKey(scopeId);
329
329
  const currentKey = getCurrentChatIdKey(scopeId);
330
330
 
331
331
  setChats(prev => {
@@ -337,7 +337,7 @@ export function ChatProvider({
337
337
  return prev;
338
338
  }
339
339
  const updatedChats = scopeChats.filter(c => c.session_id !== sessionId);
340
- LS.set(chatsKey, updatedChats);
340
+ persistChatsToLS(scopeId, updatedChats);
341
341
 
342
342
  setCurrentChatIdState(prevCurr => {
343
343
  if (prevCurr[scopeId] !== sessionId) {
@@ -348,7 +348,7 @@ export function ChatProvider({
348
348
  ? scopeChats[deletedIndex - 1].session_id
349
349
  : (updatedChats[0]?.session_id ?? null);
350
350
  if (nextId) {
351
- LS.set(currentKey, nextId);
351
+ safeLsSet(currentKey, nextId);
352
352
  } else {
353
353
  LS.remove(currentKey);
354
354
  }
@@ -395,8 +395,7 @@ export function ChatProvider({
395
395
  return chat;
396
396
  });
397
397
 
398
- const chatsKey = getChatsKey(scopeId);
399
- LS.set(chatsKey, updatedChats);
398
+ persistChatsToLS(scopeId, updatedChats);
400
399
 
401
400
  return { ...prev, [scopeId]: updatedChats };
402
401
  });
@@ -417,8 +416,7 @@ export function ChatProvider({
417
416
  messages: chat.messages.filter(m => m.id !== messageId),
418
417
  };
419
418
  });
420
- const chatsKey = getChatsKey(scopeId);
421
- LS.set(chatsKey, updatedChats);
419
+ persistChatsToLS(scopeId, updatedChats);
422
420
  return { ...prev, [scopeId]: updatedChats };
423
421
  });
424
422
  },
@@ -463,8 +461,7 @@ export function ChatProvider({
463
461
  }),
464
462
  };
465
463
  });
466
- const chatsKey = getChatsKey(scopeId);
467
- LS.set(chatsKey, updatedChats);
464
+ persistChatsToLS(scopeId, updatedChats);
468
465
  return { ...prev, [scopeId]: updatedChats };
469
466
  });
470
467
  },
@@ -487,8 +484,7 @@ export function ChatProvider({
487
484
  return { ...chat, messages: cloned };
488
485
  });
489
486
 
490
- const chatsKey = getChatsKey(scopeId);
491
- LS.set(chatsKey, updatedChats);
487
+ persistChatsToLS(scopeId, updatedChats);
492
488
 
493
489
  return { ...prev, [scopeId]: updatedChats };
494
490
  });
@@ -509,8 +505,7 @@ export function ChatProvider({
509
505
  };
510
506
  });
511
507
 
512
- const chatsKey = getChatsKey(scopeId);
513
- LS.set(chatsKey, updatedChats);
508
+ persistChatsToLS(scopeId, updatedChats);
514
509
 
515
510
  return { ...prev, [scopeId]: updatedChats };
516
511
  });
@@ -577,8 +572,7 @@ export function ChatProvider({
577
572
  : chat,
578
573
  );
579
574
 
580
- const chatsKey = getChatsKey(scopeId);
581
- LS.set(chatsKey, updatedChats);
575
+ persistChatsToLS(scopeId, updatedChats);
582
576
 
583
577
  return { ...prev, [scopeId]: updatedChats };
584
578
  });
@@ -0,0 +1,142 @@
1
+ import { MessageRole } from '#uilib/components/ui/Chat/Chat.types';
2
+ import type { Chat } from '#uilib/components/ui/Chat/Chat.types';
3
+
4
+ import {
5
+ persistChatsToLS,
6
+ stripChatsForPersistence,
7
+ stripMessageForPersistence,
8
+ } from './chatPersistence';
9
+
10
+ describe('stripMessageForPersistence', () => {
11
+ it('removes json-dashboard fences from message text', () => {
12
+ const message = stripMessageForPersistence({
13
+ id: '1',
14
+ role: MessageRole.ASSISTANT,
15
+ text: 'Summary\n```json-dashboard\n{"tiles":[]}\n```',
16
+ timestamp: 1,
17
+ });
18
+
19
+ expect(message.text).toBe('Summary');
20
+ });
21
+
22
+ it('drops attachment content but keeps file metadata', () => {
23
+ const message = stripMessageForPersistence({
24
+ id: '2',
25
+ role: MessageRole.USER,
26
+ text: 'see file',
27
+ timestamp: 2,
28
+ userTextFileAttachments: [
29
+ {
30
+ displayName: 'Report',
31
+ filename: 'report.pdf',
32
+ content: 'x'.repeat(5000),
33
+ },
34
+ ],
35
+ });
36
+
37
+ expect(message.userTextFileAttachments).toEqual([
38
+ { displayName: 'Report', filename: 'report.pdf', content: '' },
39
+ ]);
40
+ });
41
+ });
42
+
43
+ describe('stripChatsForPersistence', () => {
44
+ it('filters in-progress messages', () => {
45
+ const chats: Chat[] = [
46
+ {
47
+ session_id: 's1',
48
+ name: '',
49
+ messages: [
50
+ {
51
+ id: 'a',
52
+ role: MessageRole.SYSTEM,
53
+ text: 'Working...',
54
+ timestamp: 1,
55
+ inProgress: true,
56
+ },
57
+ {
58
+ id: 'b',
59
+ role: MessageRole.USER,
60
+ text: 'hello',
61
+ timestamp: 2,
62
+ },
63
+ ],
64
+ },
65
+ ];
66
+
67
+ expect(stripChatsForPersistence(chats)[0].messages).toHaveLength(1);
68
+ expect(stripChatsForPersistence(chats)[0].messages[0].id).toBe('b');
69
+ });
70
+ });
71
+
72
+ describe('persistChatsToLS', () => {
73
+ const quotaError = new DOMException('quota', 'QuotaExceededError');
74
+
75
+ beforeEach(() => {
76
+ window.localStorage.clear();
77
+ });
78
+
79
+ it('swallows QuotaExceededError without throwing', () => {
80
+ const setItem = jest
81
+ .spyOn(Storage.prototype, 'setItem')
82
+ .mockImplementation(() => {
83
+ throw quotaError;
84
+ });
85
+
86
+ expect(() =>
87
+ persistChatsToLS('94-__reports_new_draft__', [
88
+ {
89
+ session_id: 's1',
90
+ name: '',
91
+ messages: [
92
+ {
93
+ id: '1',
94
+ role: MessageRole.USER,
95
+ text: 'hello',
96
+ timestamp: 1,
97
+ },
98
+ ],
99
+ },
100
+ ]),
101
+ ).not.toThrow();
102
+
103
+ setItem.mockRestore();
104
+ });
105
+
106
+ it('persists only the latest session for ephemeral draft scopes', () => {
107
+ persistChatsToLS('94-__reports_new_draft__', [
108
+ {
109
+ session_id: 'current',
110
+ name: '',
111
+ messages: [
112
+ {
113
+ id: '1',
114
+ role: MessageRole.USER,
115
+ text: 'current',
116
+ timestamp: 1,
117
+ },
118
+ ],
119
+ },
120
+ {
121
+ session_id: 'old',
122
+ name: '',
123
+ messages: [
124
+ {
125
+ id: '2',
126
+ role: MessageRole.USER,
127
+ text: 'old',
128
+ timestamp: 2,
129
+ },
130
+ ],
131
+ },
132
+ ]);
133
+
134
+ const stored = window.localStorage.getItem(
135
+ 'chats-94-__reports_new_draft__',
136
+ );
137
+ expect(stored).not.toBeNull();
138
+ const parsed = JSON.parse(stored ?? '[]');
139
+ expect(parsed).toHaveLength(1);
140
+ expect(parsed[0].session_id).toBe('current');
141
+ });
142
+ });
@@ -0,0 +1,79 @@
1
+ import type {
2
+ Chat,
3
+ Message,
4
+ UserTextFileAttachment,
5
+ } from '#uilib/components/ui/Chat/Chat.types';
6
+ import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
7
+ import { LS } from '@homecode/ui';
8
+
9
+ /** Ephemeral scopes (e.g. /reports/new draft) — keep only the latest session in LS. */
10
+ const EPHEMERAL_SCOPE_MARKERS = ['__reports_new_draft__'] as const;
11
+
12
+ function isEphemeralChatScope(scopeId: string): boolean {
13
+ return EPHEMERAL_SCOPE_MARKERS.some(marker => scopeId.endsWith(marker));
14
+ }
15
+
16
+ function isQuotaExceededError(error: unknown): boolean {
17
+ if (!(error instanceof DOMException)) return false;
18
+ return (
19
+ error.name === 'QuotaExceededError' ||
20
+ error.code === 22 ||
21
+ error.code === 1014
22
+ );
23
+ }
24
+
25
+ function stripAttachmentForPersistence(
26
+ attachment: UserTextFileAttachment,
27
+ ): UserTextFileAttachment {
28
+ return {
29
+ displayName: attachment.displayName,
30
+ filename: attachment.filename,
31
+ content: '',
32
+ };
33
+ }
34
+
35
+ export function stripMessageForPersistence(message: Message): Message {
36
+ const text = stripJsonDashboardFences(message.text);
37
+ const next: Message = { ...message, text };
38
+ if (message.inProgress) {
39
+ delete next.inProgress;
40
+ }
41
+ if (message.userTextFileAttachments?.length) {
42
+ next.userTextFileAttachments = message.userTextFileAttachments.map(
43
+ stripAttachmentForPersistence,
44
+ );
45
+ }
46
+ return next;
47
+ }
48
+
49
+ export function stripChatsForPersistence(chats: Chat[]): Chat[] {
50
+ return chats.map(chat => ({
51
+ ...chat,
52
+ messages: chat.messages
53
+ .filter(message => !message.inProgress)
54
+ .map(stripMessageForPersistence),
55
+ }));
56
+ }
57
+
58
+ function chatsForScopePersistence(scopeId: string, chats: Chat[]): Chat[] {
59
+ const scopeChats = isEphemeralChatScope(scopeId) ? chats.slice(0, 1) : chats;
60
+ return stripChatsForPersistence(scopeChats);
61
+ }
62
+
63
+ export function persistChatsToLS(scopeId: string, chats: Chat[]): void {
64
+ const chatsKey = `chats-${scopeId}`;
65
+ const payload = chatsForScopePersistence(scopeId, chats);
66
+ try {
67
+ LS.set(chatsKey, payload);
68
+ } catch (error) {
69
+ if (!isQuotaExceededError(error)) throw error;
70
+ }
71
+ }
72
+
73
+ export function safeLsSet(key: string, value: unknown): void {
74
+ try {
75
+ LS.set(key, value);
76
+ } catch (error) {
77
+ if (!isQuotaExceededError(error)) throw error;
78
+ }
79
+ }
@@ -1,20 +1,19 @@
1
- import { useCallback, useMemo, useState } from 'react';
2
-
3
- import { ChartAreaInteractive } from '#uilib/components/ui/ChartAreaInteractive';
4
1
  import type {
5
2
  ChartDataPoint,
6
3
  OverlayMode,
7
4
  } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
5
+ import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
6
+ import { useCallback, useMemo, useState } from 'react';
7
+
8
+ import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
9
+ import { ChartAreaInteractive } from '#uilib/components/ui/ChartAreaInteractive';
10
+ import { DocsHeaderActions } from '../docsHeaderActions';
11
+ import type { ForecastData } from '#uilib/types/forecast-data';
8
12
  import { ForecastItemData } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
9
13
  import { Label } from '#uilib/components/ui/Label';
10
14
  import { PageContentSection } from '#uilib/components/ui/Page';
11
15
  import { Switch } from '#uilib/components/ui/Switch';
12
- import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
13
16
  import { useTheme } from '#uilib/contexts/theme-context';
14
- import type { ForecastData } from '#uilib/types/forecast-data';
15
-
16
- import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
17
- import { DocsHeaderActions } from '../docsHeaderActions';
18
17
 
19
18
  const DEMO_FORECAST_ID = 1;
20
19
 
@@ -109,6 +109,7 @@ export default function ChatAttachmentsDropzonePage() {
109
109
  those types; PDF also requires <code>allowPdfAttachments</code>.
110
110
  </p>
111
111
  <ChatChrome
112
+ variant="clean"
112
113
  showResizeHandle={false}
113
114
  resizeHandle={undefined}
114
115
  onClose={undefined}
@@ -170,6 +170,7 @@ export default function ChatComposerPrefillPage() {
170
170
  </li>
171
171
  </ul>
172
172
  <ChatChrome
173
+ variant="clean"
173
174
  showResizeHandle={false}
174
175
  resizeHandle={undefined}
175
176
  onClose={undefined}
@@ -42,7 +42,7 @@ export default function ChatPage() {
42
42
  used chips hide for the session.
43
43
  </p>
44
44
  <div className={S.chatDemoHost}>
45
- <ChatChrome {...chromeProps} />
45
+ <ChatChrome {...chromeProps} variant="clean" />
46
46
  </div>
47
47
  </PageContentSection>
48
48
  </>
@@ -115,6 +115,7 @@ export default function ChatSlashCommandsPage() {
115
115
  }
116
116
  `}</style>
117
117
  <ChatChrome
118
+ variant="clean"
118
119
  showResizeHandle={false}
119
120
  resizeHandle={undefined}
120
121
  onClose={undefined}
@@ -137,6 +137,7 @@ export default function ChatUserCsvAttachmentPage() {
137
137
  </Button>
138
138
  </p>
139
139
  <ChatChrome
140
+ variant="clean"
140
141
  showResizeHandle={false}
141
142
  resizeHandle={undefined}
142
143
  onClose={undefined}