@sybilion/uilib 1.3.79 → 1.3.81

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.
@@ -10,6 +10,7 @@ import { DropZone } from '../../DropZone/DropZone.js';
10
10
  import { PanelResizeHandle } from '../../Sidebar/Sidebar.js';
11
11
  import SidebarStem from '../../Sidebar/Sidebar.styl.js';
12
12
  import { Chat } from '../Chat.js';
13
+ import { MessageRole } from '../Chat.types.js';
13
14
  import { filterToTextAttachments, isAttachmentsDropzoneEnabled, buildAcceptAttr } from '../chatAttachmentAccept.js';
14
15
  import { extractChatAttachmentItems } from '../chatAttachmentExtract.js';
15
16
  import S from './ChatChrome.styl.js';
@@ -21,6 +22,7 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
21
22
  const [pendingAttachments, setPendingAttachments] = useState([]);
22
23
  const [isExtractingAttachments, setIsExtractingAttachments] = useState(false);
23
24
  const promptDisabled = isExtractingAttachments;
25
+ const hasInProgressSystemMessage = useMemo(() => messages.some(msg => msg.role === MessageRole.SYSTEM && msg.inProgress === true), [messages]);
24
26
  const handleAttachmentFiles = useCallback((files) => {
25
27
  if (promptDisabled || files.length === 0)
26
28
  return;
@@ -85,7 +87,9 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
85
87
  const label = displayLabelForBranchKeyFromMessages(key, messages) ??
86
88
  humanizeBranchKey(key);
87
89
  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));
88
- }) })) : null, showInlinePresets && renderPresets('inline'), isLoading && (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 })] })] })] })] }));
90
+ }) })) : null, showInlinePresets && renderPresets('inline'), isLoading &&
91
+ !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 })] })] })] })] }));
89
93
  }
90
94
 
91
95
  export { ChatChrome };
@@ -68,7 +68,9 @@ function displayTextFromSendPayload(message) {
68
68
  }
69
69
  /** Optional loading shimmer label from a structured send payload. */
70
70
  function loadingLabelFromSendPayload(message) {
71
- return typeof message === 'string' ? undefined : message.loadingLabel;
71
+ if (typeof message === 'string')
72
+ return undefined;
73
+ return message.loadingLabel ?? message.systemProgressLabel;
72
74
  }
73
75
 
74
76
  export { buildChatSendMessagePayload, displayTextFromSendPayload, loadingLabelFromSendPayload, normalizeUserTextFileAttachments };
@@ -97,12 +97,14 @@ function shiftNormalizedSeriesBackward(series, lagMonths) {
97
97
  function applyDriversComparisonViewToPayload(payload, tab) {
98
98
  if (!payload || tab === 'lagged')
99
99
  return payload;
100
+ if (!payload.target?.normalized_series)
101
+ return payload;
100
102
  return {
101
103
  target: {
102
104
  ...payload.target,
103
105
  normalized_series: { ...payload.target.normalized_series },
104
106
  },
105
- drivers: payload.drivers.map(driver => {
107
+ drivers: (payload.drivers ?? []).map(driver => {
106
108
  const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
107
109
  const series = driver.normalized_series ?? {};
108
110
  const normalized_series = lagMonths != null && lagMonths > 0
@@ -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]);
@@ -343,33 +337,45 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
343
337
  userTextFileAttachments: normalizeUserTextFileAttachments(message),
344
338
  });
345
339
  }
340
+ let progressMessageId;
341
+ if (typeof message !== 'string' && message.systemProgressLabel) {
342
+ progressMessageId = addMessage(scopeId, targetChatId, MessageRole.SYSTEM, message.systemProgressLabel, { inProgress: true });
343
+ }
346
344
  const pendingChatSessionId = targetChatId;
347
345
  beginOutboundPending(scopeId, pendingChatSessionId);
346
+ let effectiveSessionId = pendingChatSessionId;
348
347
  try {
349
348
  const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
350
- const effectiveSessionId = data.session_id && data.session_id !== pendingChatSessionId
351
- ? data.session_id
352
- : pendingChatSessionId;
349
+ effectiveSessionId =
350
+ data.session_id && data.session_id !== pendingChatSessionId
351
+ ? data.session_id
352
+ : pendingChatSessionId;
353
353
  if (data.session_id && data.session_id !== pendingChatSessionId) {
354
354
  setChats(prev => {
355
355
  const scopeChats = prev[scopeId] ?? [];
356
356
  const updatedChats = scopeChats.map(chat => chat.session_id === pendingChatSessionId
357
357
  ? { ...chat, session_id: data.session_id }
358
358
  : chat);
359
- const chatsKey = getChatsKey(scopeId);
360
- LS.set(chatsKey, updatedChats);
359
+ persistChatsToLS(scopeId, updatedChats);
361
360
  return { ...prev, [scopeId]: updatedChats };
362
361
  });
363
362
  setCurrentChatId(scopeId, data.session_id);
364
363
  }
364
+ if (progressMessageId) {
365
+ removeMessageById(scopeId, effectiveSessionId, progressMessageId);
366
+ progressMessageId = undefined;
367
+ }
365
368
  addMessage(scopeId, effectiveSessionId, MessageRole.ASSISTANT, data.response);
366
369
  return { response: data.response, sessionId: effectiveSessionId };
367
370
  }
368
371
  catch (error) {
372
+ if (progressMessageId) {
373
+ removeMessageById(scopeId, effectiveSessionId, progressMessageId);
374
+ }
369
375
  const errorMessage = error instanceof Error
370
376
  ? error.message
371
377
  : 'Sorry, I encountered an error processing your message. Please try again.';
372
- addMessage(scopeId, pendingChatSessionId, MessageRole.ASSISTANT, errorMessage);
378
+ addMessage(scopeId, effectiveSessionId, MessageRole.ASSISTANT, errorMessage);
373
379
  throw error;
374
380
  }
375
381
  finally {
@@ -377,6 +383,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
377
383
  }
378
384
  }, [
379
385
  addMessage,
386
+ removeMessageById,
380
387
  beginOutboundPending,
381
388
  endOutboundPending,
382
389
  getCurrentChatId,
@@ -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 };
@@ -21,6 +21,8 @@ export type ChatSendMessagePayload = {
21
21
  omitUserMessage?: boolean;
22
22
  /** Shimmer label while waiting on the API; defaults to "Thinking...". */
23
23
  loadingLabel?: string;
24
+ /** When set, show as a SYSTEM inProgress bubble instead of the footer shimmer. */
25
+ systemProgressLabel?: string;
24
26
  };
25
27
  export type ChatMetaValue = string | number | boolean | null;
26
28
  export type ChatMeta = Record<string, ChatMetaValue>;
@@ -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.79",
3
+ "version": "1.3.81",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -29,6 +29,8 @@ export type ChatSendMessagePayload = {
29
29
  omitUserMessage?: boolean;
30
30
  /** Shimmer label while waiting on the API; defaults to "Thinking...". */
31
31
  loadingLabel?: string;
32
+ /** When set, show as a SYSTEM inProgress bubble instead of the footer shimmer. */
33
+ systemProgressLabel?: string;
32
34
  };
33
35
 
34
36
  export type ChatMetaValue = string | number | boolean | null;
@@ -14,7 +14,7 @@ import { DropZone } from '../../DropZone/DropZone';
14
14
  import { PanelResizeHandle } from '../../Sidebar/Sidebar';
15
15
  import SidebarStem from '../../Sidebar/Sidebar.styl';
16
16
  import { Chat } from '../Chat';
17
- import type { ChatAttachmentDropItem } from '../Chat.types';
17
+ import { type ChatAttachmentDropItem, MessageRole } from '../Chat.types';
18
18
  import {
19
19
  buildAcceptAttr,
20
20
  filterToTextAttachments,
@@ -76,6 +76,13 @@ export function ChatChrome({
76
76
  >([]);
77
77
  const [isExtractingAttachments, setIsExtractingAttachments] = useState(false);
78
78
  const promptDisabled = isExtractingAttachments;
79
+ const hasInProgressSystemMessage = useMemo(
80
+ () =>
81
+ messages.some(
82
+ msg => msg.role === MessageRole.SYSTEM && msg.inProgress === true,
83
+ ),
84
+ [messages],
85
+ );
79
86
 
80
87
  const handleAttachmentFiles = useCallback(
81
88
  (files: File[]) => {
@@ -263,11 +270,13 @@ export function ChatChrome({
263
270
 
264
271
  {showInlinePresets && renderPresets('inline')}
265
272
 
266
- {isLoading && (isLastMessageFromUser || loadingLabel) && (
267
- <TextShimmer duration={1} spread={5} className={S.loader}>
268
- {loadingLabel ?? 'Thinking...'}
269
- </TextShimmer>
270
- )}
273
+ {isLoading &&
274
+ !hasInProgressSystemMessage &&
275
+ (isLastMessageFromUser || loadingLabel) && (
276
+ <TextShimmer duration={1} spread={5} className={S.loader}>
277
+ {loadingLabel ?? 'Thinking...'}
278
+ </TextShimmer>
279
+ )}
271
280
  </Scroll>
272
281
  </div>
273
282
  )}
@@ -85,5 +85,6 @@ export function displayTextFromSendPayload(
85
85
  export function loadingLabelFromSendPayload(
86
86
  message: string | ChatSendMessagePayload,
87
87
  ): string | undefined {
88
- return typeof message === 'string' ? undefined : message.loadingLabel;
88
+ if (typeof message === 'string') return undefined;
89
+ return message.loadingLabel ?? message.systemProgressLabel;
89
90
  }
@@ -127,7 +127,16 @@ describe('applyDriversComparisonViewToPayload', () => {
127
127
  };
128
128
 
129
129
  it('returns original payload for lagged tab', () => {
130
- expect(applyDriversComparisonViewToPayload(payload, 'lagged')).toBe(payload);
130
+ expect(applyDriversComparisonViewToPayload(payload, 'lagged')).toBe(
131
+ payload,
132
+ );
133
+ });
134
+
135
+ it('returns original payload for overlapped tab when target series missing', () => {
136
+ const incomplete = { drivers: payload.drivers } as typeof payload;
137
+ expect(applyDriversComparisonViewToPayload(incomplete, 'overlapped')).toBe(
138
+ incomplete,
139
+ );
131
140
  });
132
141
 
133
142
  it('shifts driver series backward for overlapped tab without mutating source', () => {
@@ -253,6 +262,9 @@ describe('buildDriversComparisonChartData historical window floor', () => {
253
262
  };
254
263
 
255
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 },
256
268
  { date: '2014-10-01', historical: 100 },
257
269
  { date: '2014-11-01', historical: 101 },
258
270
  { date: '2014-12-01', historical: 102 },
@@ -327,8 +339,8 @@ describe('buildDriversComparisonChartData historical window floor', () => {
327
339
 
328
340
  expect(laggedChart[0]?.date).toBe('2014-10-01');
329
341
  expect(overlappedChart[0]?.date).toBe('2014-07-01');
330
- expect(
331
- overlappedChart[0]?.date!.localeCompare(laggedChart[0]?.date!),
332
- ).toBe(-1);
342
+ expect(overlappedChart[0]?.date!.localeCompare(laggedChart[0]?.date!)).toBe(
343
+ -1,
344
+ );
333
345
  });
334
346
  });
@@ -125,13 +125,14 @@ export function applyDriversComparisonViewToPayload(
125
125
  tab: DriversComparisonViewTab,
126
126
  ): BacktestsComponentPayload | null {
127
127
  if (!payload || tab === 'lagged') return payload;
128
+ if (!payload.target?.normalized_series) return payload;
128
129
 
129
130
  return {
130
131
  target: {
131
132
  ...payload.target,
132
133
  normalized_series: { ...payload.target.normalized_series },
133
134
  },
134
- drivers: payload.drivers.map(driver => {
135
+ drivers: (payload.drivers ?? []).map(driver => {
135
136
  const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
136
137
  const series = driver.normalized_series ?? {};
137
138
  const normalized_series =
@@ -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
  });
@@ -546,12 +541,24 @@ export function ChatProvider({
546
541
  );
547
542
  }
548
543
 
544
+ let progressMessageId: string | undefined;
545
+ if (typeof message !== 'string' && message.systemProgressLabel) {
546
+ progressMessageId = addMessage(
547
+ scopeId,
548
+ targetChatId,
549
+ MessageRole.SYSTEM,
550
+ message.systemProgressLabel,
551
+ { inProgress: true },
552
+ );
553
+ }
554
+
549
555
  const pendingChatSessionId = targetChatId;
550
556
  beginOutboundPending(scopeId, pendingChatSessionId);
557
+ let effectiveSessionId = pendingChatSessionId;
551
558
  try {
552
559
  const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
553
560
 
554
- const effectiveSessionId =
561
+ effectiveSessionId =
555
562
  data.session_id && data.session_id !== pendingChatSessionId
556
563
  ? data.session_id
557
564
  : pendingChatSessionId;
@@ -565,14 +572,18 @@ export function ChatProvider({
565
572
  : chat,
566
573
  );
567
574
 
568
- const chatsKey = getChatsKey(scopeId);
569
- LS.set(chatsKey, updatedChats);
575
+ persistChatsToLS(scopeId, updatedChats);
570
576
 
571
577
  return { ...prev, [scopeId]: updatedChats };
572
578
  });
573
579
  setCurrentChatId(scopeId, data.session_id);
574
580
  }
575
581
 
582
+ if (progressMessageId) {
583
+ removeMessageById(scopeId, effectiveSessionId, progressMessageId);
584
+ progressMessageId = undefined;
585
+ }
586
+
576
587
  addMessage(
577
588
  scopeId,
578
589
  effectiveSessionId,
@@ -582,6 +593,10 @@ export function ChatProvider({
582
593
 
583
594
  return { response: data.response, sessionId: effectiveSessionId };
584
595
  } catch (error) {
596
+ if (progressMessageId) {
597
+ removeMessageById(scopeId, effectiveSessionId, progressMessageId);
598
+ }
599
+
585
600
  const errorMessage =
586
601
  error instanceof Error
587
602
  ? error.message
@@ -589,7 +604,7 @@ export function ChatProvider({
589
604
 
590
605
  addMessage(
591
606
  scopeId,
592
- pendingChatSessionId,
607
+ effectiveSessionId,
593
608
  MessageRole.ASSISTANT,
594
609
  errorMessage,
595
610
  );
@@ -600,6 +615,7 @@ export function ChatProvider({
600
615
  },
601
616
  [
602
617
  addMessage,
618
+ removeMessageById,
603
619
  beginOutboundPending,
604
620
  endOutboundPending,
605
621
  getCurrentChatId,
@@ -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
+ }