@sybilion/uilib 1.3.80 → 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.
- package/dist/esm/contexts/chat-context.js +13 -20
- package/dist/esm/contexts/chatPersistence.js +67 -0
- package/dist/esm/types/src/contexts/chatPersistence.d.ts +5 -0
- package/dist/esm/types/src/contexts/chatPersistence.test.d.ts +1 -0
- package/dist/esm/types/tests/mocks/homecodeUiMock.d.ts +6 -0
- package/package.json +1 -1
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts +3 -0
- package/src/contexts/chat-context.tsx +14 -20
- package/src/contexts/chatPersistence.test.ts +142 -0
- package/src/contexts/chatPersistence.ts +79 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|