@sybilion/uilib 1.3.87 → 1.3.89

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 (41) hide show
  1. package/dist/esm/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.js +29 -21
  2. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +2 -1
  3. package/dist/esm/components/ui/Chat/ChatSheet/ChatSheet.js +1 -0
  4. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +36 -10
  5. package/dist/esm/components/ui/Sidebar/Sidebar.js +23 -7
  6. package/dist/esm/contexts/chat-context.js +180 -205
  7. package/dist/esm/contexts/chatPersistence.js +6 -18
  8. package/dist/esm/contexts/chatSessionStorage.js +245 -0
  9. package/dist/esm/lib/dashboard-spec/jsonDashboardFence.js +80 -0
  10. package/dist/esm/types/src/components/ui/Chat/ChatEmptyState/ChatEmptyState.types.d.ts +5 -1
  11. package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.d.ts +1 -5
  12. package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.d.ts +1 -0
  13. package/dist/esm/types/src/components/ui/Chat/ChatSheet/ChatSheet.d.ts +2 -0
  14. package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +8 -1
  15. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -1
  16. package/dist/esm/types/src/components/ui/Sidebar/Sidebar.d.ts +2 -0
  17. package/dist/esm/types/src/contexts/chat-context.d.ts +6 -0
  18. package/dist/esm/types/src/contexts/chatPersistence.d.ts +4 -1
  19. package/dist/esm/types/src/contexts/chatSessionStorage.d.ts +32 -0
  20. package/dist/esm/types/src/contexts/chatSessionStorage.test.d.ts +1 -0
  21. package/dist/esm/types/src/lib/dashboard-spec/jsonDashboardFence.d.ts +9 -0
  22. package/dist/esm/types/src/lib/dashboard-spec/stripJsonDashboardFences.d.ts +1 -2
  23. package/dist/esm/types/src/lib/dashboard-spec/stripJsonDashboardFences.test.d.ts +1 -0
  24. package/package.json +1 -1
  25. package/src/components/ui/Chat/ChatEmptyState/ChatEmptyState.types.ts +9 -1
  26. package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.tsx +25 -0
  27. package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.tsx +35 -26
  28. package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +2 -1
  29. package/src/components/ui/Chat/ChatSheet/ChatSheet.tsx +3 -0
  30. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +55 -10
  31. package/src/components/ui/Chat/index.ts +1 -0
  32. package/src/components/ui/Sidebar/Sidebar.tsx +27 -8
  33. package/src/contexts/chat-context.tsx +253 -220
  34. package/src/contexts/chatPersistence.test.ts +11 -0
  35. package/src/contexts/chatPersistence.ts +22 -6
  36. package/src/contexts/chatSessionStorage.test.ts +125 -0
  37. package/src/contexts/chatSessionStorage.ts +321 -0
  38. package/src/lib/dashboard-spec/jsonDashboardFence.ts +98 -0
  39. package/src/lib/dashboard-spec/stripJsonDashboardFences.test.ts +84 -0
  40. package/src/lib/dashboard-spec/stripJsonDashboardFences.ts +5 -6
  41. package/dist/esm/lib/dashboard-spec/stripJsonDashboardFences.js +0 -7
@@ -17,11 +17,27 @@ import {
17
17
  type UserTextFileAttachment,
18
18
  } from '#uilib/components/ui/Chat/Chat.types';
19
19
  import { normalizeUserTextFileAttachments } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
20
- import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
20
+ import {
21
+ normalizeJsonDashboardChatText,
22
+ stripJsonDashboardFences,
23
+ } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
21
24
  import type { ChatResponse } from '#uilib/types/chat-api.types';
22
- import { LS } from '@homecode/ui';
23
25
 
24
- import { persistChatsToLS, safeLsSet } from './chatPersistence';
26
+ import { isEphemeralChatScope } from './chatPersistence';
27
+ import {
28
+ addScopeIdToRegistry,
29
+ countSessionReferences,
30
+ deleteSessionBlob,
31
+ linkSessionToScope as linkSessionToScopeStorage,
32
+ loadChatsFromSessionStorage,
33
+ loadScopeIndex,
34
+ loadSession,
35
+ persistScopeIndex,
36
+ persistSession,
37
+ removeSessionFromScope,
38
+ renameSessionId,
39
+ } from './chatSessionStorage';
40
+ import type { LinkSessionToScopeOptions } from './chatSessionStorage';
25
41
 
26
42
  export type SendChatMessageFn = (
27
43
  message: string,
@@ -33,9 +49,6 @@ export type {
33
49
  UserTextFileAttachment,
34
50
  } from '#uilib/components/ui/Chat/Chat.types';
35
51
 
36
- const CHATS_PREFIX = 'chats-';
37
- const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
38
-
39
52
  export type AddChatMessageOptions = {
40
53
  userTextFileAttachments?: UserTextFileAttachment[];
41
54
  /** SYSTEM-only: mark as transient progress placeholder (shimmer until removed). */
@@ -55,6 +68,8 @@ export type NewChatOptions = {
55
68
  seedMessages?: readonly Message[];
56
69
  };
57
70
 
71
+ export type { LinkSessionToScopeOptions } from './chatSessionStorage';
72
+
58
73
  export type SendMessageResult = {
59
74
  response: string;
60
75
  sessionId: string;
@@ -63,6 +78,12 @@ export type SendMessageResult = {
63
78
  export interface ChatContextType {
64
79
  /** Returns the new session id, or undefined if no user / not created. */
65
80
  newChat: (scopeId: string, options?: NewChatOptions) => string | undefined;
81
+ /** Attach an existing session to a scope without cloning messages. */
82
+ linkSessionToScope: (
83
+ scopeId: string,
84
+ sessionId: string,
85
+ options?: LinkSessionToScopeOptions,
86
+ ) => void;
66
87
  setCurrentChatId: (currScopeId: string, sessionId: string) => void;
67
88
  addMessage: (
68
89
  scopeId: string,
@@ -116,78 +137,43 @@ export function outboundPendingKey(scopeId: string, chatSessionId: string) {
116
137
  return `${scopeId}\0${chatSessionId}`;
117
138
  }
118
139
 
119
- function getCurrentChatIdKey(scopeId: string) {
120
- return `chat-current-id-${scopeId}`;
121
- }
122
-
123
- function getChatsKey(scopeId: string) {
124
- return `${CHATS_PREFIX}${scopeId}`;
125
- }
126
-
127
- function scopeIdsForUser(userId: number, all: Set<string>): Set<string> {
128
- const prefix = `${userId}-`;
129
- const out = new Set<string>();
130
- for (const id of all) {
131
- if (id.startsWith(prefix)) out.add(id);
132
- }
133
- return out;
134
- }
135
-
136
- function discoverScopeIdsFromLS(): Set<string> {
137
- const scopeIds = new Set<string>();
138
- const registryRaw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
139
- const registry = Array.isArray(registryRaw) ? registryRaw : [];
140
- registry.forEach((id: unknown) => {
141
- if (typeof id === 'string' && id) scopeIds.add(id);
142
- });
143
- try {
144
- for (let i = 0; i < window.localStorage.length; i++) {
145
- const key = window.localStorage.key(i);
146
- if (key?.startsWith(CHATS_PREFIX)) {
147
- const scopeId = key.slice(CHATS_PREFIX.length);
148
- if (scopeId) scopeIds.add(scopeId);
149
- }
150
- }
151
- } catch {
152
- // ignore
153
- }
154
- return scopeIds;
155
- }
156
-
157
- function loadChatsFromLS(userId: number | null): {
158
- chats: Record<string, Chat[]>;
159
- currentChatId: Record<string, string | null>;
160
- } {
161
- if (typeof window === 'undefined' || userId === null) {
162
- return { chats: {}, currentChatId: {} };
163
- }
164
- const chats: Record<string, Chat[]> = {};
165
- const currentChatId: Record<string, string | null> = {};
166
- const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
167
- for (const scopeId of scopeIds) {
168
- const chatsKey = getChatsKey(scopeId);
169
- const storedRaw = LS.get(chatsKey);
170
- const stored = Array.isArray(storedRaw) ? storedRaw : undefined;
171
- if (stored?.length) {
172
- chats[scopeId] = stored;
173
- const ck = getCurrentChatIdKey(scopeId);
174
- const savedCurrentIdRaw = LS.get(ck);
175
- const savedCurrentId =
176
- typeof savedCurrentIdRaw === 'string' && savedCurrentIdRaw !== ''
177
- ? savedCurrentIdRaw
178
- : undefined;
179
- currentChatId[scopeId] = savedCurrentId ?? stored[0]?.session_id ?? null;
140
+ function applySessionUpdate(
141
+ prev: Record<string, Chat[]>,
142
+ sessionId: string,
143
+ updater: (chat: Chat) => Chat,
144
+ ): Record<string, Chat[]> {
145
+ let updatedChat: Chat | null = null;
146
+ const next: Record<string, Chat[]> = {};
147
+ for (const [scopeId, scopeChats] of Object.entries(prev)) {
148
+ const idx = scopeChats.findIndex(chat => chat.session_id === sessionId);
149
+ if (idx === -1) {
150
+ next[scopeId] = scopeChats;
151
+ continue;
180
152
  }
153
+ const chat = updater(scopeChats[idx]);
154
+ updatedChat = chat;
155
+ const scopeNext = [...scopeChats];
156
+ scopeNext[idx] = chat;
157
+ next[scopeId] = scopeNext;
181
158
  }
182
- return { chats, currentChatId };
159
+ if (updatedChat) persistSession(updatedChat);
160
+ return next;
183
161
  }
184
162
 
185
- function addScopeIdToRegistry(scopeId: string) {
186
- const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
187
- const registry = Array.isArray(raw) ? [...raw] : [];
188
- if (!registry.includes(scopeId)) {
189
- safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
163
+ function remapSessionIdInMemory(
164
+ prev: Record<string, Chat[]>,
165
+ oldSessionId: string,
166
+ newSessionId: string,
167
+ ): Record<string, Chat[]> {
168
+ const next: Record<string, Chat[]> = {};
169
+ for (const [scopeId, scopeChats] of Object.entries(prev)) {
170
+ next[scopeId] = scopeChats.map(chat =>
171
+ chat.session_id === oldSessionId
172
+ ? { ...chat, session_id: newSessionId }
173
+ : chat,
174
+ );
190
175
  }
176
+ return next;
191
177
  }
192
178
 
193
179
  /** Shallow-clone messages for seeding another session; drops in-progress rows. */
@@ -196,6 +182,7 @@ function cloneMessagesForNewSession(messages: readonly Message[]): Message[] {
196
182
  .filter(message => !message.inProgress)
197
183
  .map(message => ({
198
184
  ...message,
185
+ text: stripJsonDashboardFences(message.text),
199
186
  ...(message.meta ? { meta: { ...message.meta } } : {}),
200
187
  ...(message.userTextFileAttachments
201
188
  ? {
@@ -220,14 +207,14 @@ export function ChatProvider({
220
207
  sendChatMessage: sendChatMessageFn,
221
208
  }: ChatProviderProps) {
222
209
  const [chats, setChats] = useState<Record<string, Chat[]>>(() => {
223
- if (userSwitchKey === null) return {};
224
- return loadChatsFromLS(userSwitchKey).chats;
210
+ if (userSwitchKey === null || typeof window === 'undefined') return {};
211
+ return loadChatsFromSessionStorage(userSwitchKey).chats;
225
212
  });
226
213
  const [currentChatId, setCurrentChatIdState] = useState<
227
214
  Record<string, string | null>
228
215
  >(() => {
229
- if (userSwitchKey === null) return {};
230
- return loadChatsFromLS(userSwitchKey).currentChatId;
216
+ if (userSwitchKey === null || typeof window === 'undefined') return {};
217
+ return loadChatsFromSessionStorage(userSwitchKey).currentChatId;
231
218
  });
232
219
 
233
220
  const [outboundPendingByKey, setOutboundPendingByKey] = useState<
@@ -282,6 +269,68 @@ export function ChatProvider({
282
269
  [currentChatId],
283
270
  );
284
271
 
272
+ const setCurrentChatId = useCallback(
273
+ (currScopeId: string, sessionId: string) => {
274
+ if (!sessionId) return;
275
+ setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
276
+ const index = loadScopeIndex(currScopeId);
277
+ if (index) {
278
+ persistScopeIndex(currScopeId, {
279
+ ...index,
280
+ currentSessionId: sessionId,
281
+ });
282
+ }
283
+ },
284
+ [],
285
+ );
286
+
287
+ const linkSessionToScope = useCallback(
288
+ (
289
+ scopeId: string,
290
+ sessionId: string,
291
+ options?: LinkSessionToScopeOptions,
292
+ ) => {
293
+ if (userSwitchKey === null) return;
294
+ linkSessionToScopeStorage(scopeId, sessionId, options);
295
+ setChats(prev => {
296
+ const sessionFromLs = loadSession(sessionId);
297
+ let sessionFromMemory: Chat | null = null;
298
+ for (const scopeChats of Object.values(prev)) {
299
+ const found = scopeChats.find(chat => chat.session_id === sessionId);
300
+ if (
301
+ found &&
302
+ (!sessionFromMemory ||
303
+ found.messages.length > sessionFromMemory.messages.length)
304
+ ) {
305
+ sessionFromMemory = found;
306
+ }
307
+ }
308
+ const session =
309
+ sessionFromMemory &&
310
+ (!sessionFromLs ||
311
+ sessionFromMemory.messages.length >= sessionFromLs.messages.length)
312
+ ? sessionFromMemory
313
+ : sessionFromLs;
314
+ if (!session) return prev;
315
+ if (session === sessionFromMemory) {
316
+ persistSession(sessionFromMemory);
317
+ }
318
+ const scopeChats = prev[scopeId] ?? [];
319
+ const exists = scopeChats.some(chat => chat.session_id === sessionId);
320
+ const updatedChats = exists
321
+ ? scopeChats.map(chat =>
322
+ chat.session_id === sessionId ? session : chat,
323
+ )
324
+ : [session, ...scopeChats];
325
+ return { ...prev, [scopeId]: updatedChats };
326
+ });
327
+ if (options?.makeCurrent !== false) {
328
+ setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
329
+ }
330
+ },
331
+ [userSwitchKey],
332
+ );
333
+
285
334
  const newChat = useCallback(
286
335
  (scopeId: string, options?: NewChatOptions): string | undefined => {
287
336
  if (userSwitchKey === null) return undefined;
@@ -292,72 +341,74 @@ export function ChatProvider({
292
341
  options?.seedMessages && options.seedMessages.length > 0
293
342
  ? cloneMessagesForNewSession(options.seedMessages)
294
343
  : [];
295
- const newChat: Chat = {
344
+ const created: Chat = {
296
345
  session_id: sessionId,
297
346
  name: '',
298
347
  messages: seededMessages,
299
348
  };
300
349
 
301
- setChats(prev => {
302
- const currentChats = prev[scopeId] ?? [];
303
- const updatedChats = [newChat, ...currentChats];
304
-
305
- persistChatsToLS(scopeId, updatedChats);
350
+ persistSession(created);
351
+ linkSessionToScopeStorage(scopeId, sessionId, { makeCurrent: true });
306
352
 
307
- return {
308
- ...prev,
309
- [scopeId]: updatedChats,
310
- };
353
+ setChats(prev => {
354
+ const scopeChats = prev[scopeId] ?? [];
355
+ const isEphemeral = isEphemeralChatScope(scopeId);
356
+ if (isEphemeral) {
357
+ for (const old of scopeChats) {
358
+ if (old.session_id !== sessionId) {
359
+ removeSessionFromScope(scopeId, old.session_id);
360
+ if (countSessionReferences(userSwitchKey, old.session_id) === 0) {
361
+ deleteSessionBlob(old.session_id);
362
+ }
363
+ }
364
+ }
365
+ }
366
+ const updatedChats = isEphemeral ? [created] : [created, ...scopeChats];
367
+ return { ...prev, [scopeId]: updatedChats };
311
368
  });
312
369
  setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
313
- safeLsSet(getCurrentChatIdKey(scopeId), sessionId);
314
370
  return sessionId;
315
371
  },
316
372
  [userSwitchKey],
317
373
  );
318
374
 
319
- const setCurrentChatId = useCallback(
320
- (currScopeId: string, sessionId: string) => {
321
- if (!sessionId) return;
322
- setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
323
- safeLsSet(getCurrentChatIdKey(currScopeId), sessionId);
324
- },
325
- [],
326
- );
327
-
328
- const deleteChat = useCallback((scopeId: string, sessionId: string) => {
329
- const currentKey = getCurrentChatIdKey(scopeId);
330
-
331
- setChats(prev => {
332
- const scopeChats = prev[scopeId] ?? [];
375
+ const deleteChat = useCallback(
376
+ (scopeId: string, sessionId: string) => {
377
+ const scopeChats = chats[scopeId] ?? [];
333
378
  const deletedIndex = scopeChats.findIndex(
334
- c => c.session_id === sessionId,
379
+ chat => chat.session_id === sessionId,
335
380
  );
336
- if (deletedIndex === -1) {
337
- return prev;
381
+ if (deletedIndex === -1) return;
382
+
383
+ removeSessionFromScope(scopeId, sessionId);
384
+ if (
385
+ userSwitchKey != null &&
386
+ countSessionReferences(userSwitchKey, sessionId) === 0
387
+ ) {
388
+ deleteSessionBlob(sessionId);
338
389
  }
339
- const updatedChats = scopeChats.filter(c => c.session_id !== sessionId);
340
- persistChatsToLS(scopeId, updatedChats);
390
+
391
+ setChats(prev => {
392
+ const currentScopeChats = prev[scopeId] ?? [];
393
+ return {
394
+ ...prev,
395
+ [scopeId]: currentScopeChats.filter(
396
+ chat => chat.session_id !== sessionId,
397
+ ),
398
+ };
399
+ });
341
400
 
342
401
  setCurrentChatIdState(prevCurr => {
343
- if (prevCurr[scopeId] !== sessionId) {
344
- return prevCurr;
345
- }
402
+ if (prevCurr[scopeId] !== sessionId) return prevCurr;
403
+ const index = loadScopeIndex(scopeId);
346
404
  const nextId =
347
- deletedIndex > 0
348
- ? scopeChats[deletedIndex - 1].session_id
349
- : (updatedChats[0]?.session_id ?? null);
350
- if (nextId) {
351
- safeLsSet(currentKey, nextId);
352
- } else {
353
- LS.remove(currentKey);
354
- }
405
+ index?.currentSessionId ??
406
+ (deletedIndex > 0 ? scopeChats[deletedIndex - 1].session_id : null);
355
407
  return { ...prevCurr, [scopeId]: nextId };
356
408
  });
357
-
358
- return { ...prev, [scopeId]: updatedChats };
359
- });
360
- }, []);
409
+ },
410
+ [chats, userSwitchKey],
411
+ );
361
412
 
362
413
  const addMessage = useCallback(
363
414
  (
@@ -369,7 +420,10 @@ export function ChatProvider({
369
420
  ) => {
370
421
  if (userSwitchKey === null) return undefined;
371
422
  addScopeIdToRegistry(scopeId);
372
- const storedText = stripJsonDashboardFences(text);
423
+ const storedText =
424
+ role === MessageRole.ASSISTANT
425
+ ? normalizeJsonDashboardChatText(text)
426
+ : stripJsonDashboardFences(text);
373
427
  const attachments =
374
428
  role === MessageRole.USER
375
429
  ? options?.userTextFileAttachments
@@ -386,19 +440,12 @@ export function ChatProvider({
386
440
  ...(options?.meta ? { meta: { ...options.meta } } : {}),
387
441
  };
388
442
 
389
- setChats(prev => {
390
- const scopeChats = prev[scopeId] ?? [];
391
- const updatedChats = scopeChats.map(chat => {
392
- if (chat.session_id === chatId) {
393
- return { ...chat, messages: [...chat.messages, newMessage] };
394
- }
395
- return chat;
396
- });
397
-
398
- persistChatsToLS(scopeId, updatedChats);
399
-
400
- return { ...prev, [scopeId]: updatedChats };
401
- });
443
+ setChats(prev =>
444
+ applySessionUpdate(prev, chatId, chat => ({
445
+ ...chat,
446
+ messages: [...chat.messages, newMessage],
447
+ })),
448
+ );
402
449
  return newMessage.id;
403
450
  },
404
451
  [userSwitchKey],
@@ -407,18 +454,12 @@ export function ChatProvider({
407
454
  const removeMessageById = useCallback(
408
455
  (scopeId: string, chatId: string, messageId: string) => {
409
456
  if (userSwitchKey === null) return;
410
- setChats(prev => {
411
- const scopeChats = prev[scopeId] ?? [];
412
- const updatedChats = scopeChats.map(chat => {
413
- if (chat.session_id !== chatId) return chat;
414
- return {
415
- ...chat,
416
- messages: chat.messages.filter(m => m.id !== messageId),
417
- };
418
- });
419
- persistChatsToLS(scopeId, updatedChats);
420
- return { ...prev, [scopeId]: updatedChats };
421
- });
457
+ setChats(prev =>
458
+ applySessionUpdate(prev, chatId, chat => ({
459
+ ...chat,
460
+ messages: chat.messages.filter(message => message.id !== messageId),
461
+ })),
462
+ );
422
463
  },
423
464
  [userSwitchKey],
424
465
  );
@@ -431,39 +472,37 @@ export function ChatProvider({
431
472
  patch: UpdateChatMessagePatch,
432
473
  ) => {
433
474
  if (userSwitchKey === null) return;
434
- setChats(prev => {
435
- const scopeChats = prev[scopeId] ?? [];
436
- const updatedChats = scopeChats.map(chat => {
437
- if (chat.session_id !== chatId) return chat;
438
- return {
439
- ...chat,
440
- messages: chat.messages.map(message => {
441
- if (message.id !== messageId) return message;
442
- const next: Message = { ...message };
443
- if (patch.role != null) {
444
- next.role = patch.role;
445
- }
446
- if (patch.text != null) {
447
- next.text = stripJsonDashboardFences(patch.text);
448
- }
449
- if (patch.inProgress === true) {
450
- next.inProgress = true;
451
- } else if (
452
- patch.inProgress === false ||
453
- (patch.role != null && patch.role !== MessageRole.SYSTEM)
454
- ) {
455
- delete next.inProgress;
456
- }
457
- if (patch.meta != null) {
458
- next.meta = { ...next.meta, ...patch.meta };
459
- }
460
- return next;
461
- }),
462
- };
463
- });
464
- persistChatsToLS(scopeId, updatedChats);
465
- return { ...prev, [scopeId]: updatedChats };
466
- });
475
+ setChats(prev =>
476
+ applySessionUpdate(prev, chatId, chat => ({
477
+ ...chat,
478
+ messages: chat.messages.map(message => {
479
+ if (message.id !== messageId) return message;
480
+ const next: Message = { ...message };
481
+ if (patch.role != null) {
482
+ next.role = patch.role;
483
+ }
484
+ if (patch.text != null) {
485
+ const targetRole = patch.role ?? message.role;
486
+ next.text =
487
+ targetRole === MessageRole.ASSISTANT
488
+ ? normalizeJsonDashboardChatText(patch.text)
489
+ : stripJsonDashboardFences(patch.text);
490
+ }
491
+ if (patch.inProgress === true) {
492
+ next.inProgress = true;
493
+ } else if (
494
+ patch.inProgress === false ||
495
+ (patch.role != null && patch.role !== MessageRole.SYSTEM)
496
+ ) {
497
+ delete next.inProgress;
498
+ }
499
+ if (patch.meta != null) {
500
+ next.meta = { ...next.meta, ...patch.meta };
501
+ }
502
+ return next;
503
+ }),
504
+ })),
505
+ );
467
506
  },
468
507
  [userSwitchKey],
469
508
  );
@@ -474,20 +513,19 @@ export function ChatProvider({
474
513
  addScopeIdToRegistry(scopeId);
475
514
  const cloned = messages.map(message => ({
476
515
  ...message,
516
+ text:
517
+ message.role === MessageRole.ASSISTANT
518
+ ? message.text
519
+ : stripJsonDashboardFences(message.text),
477
520
  ...(message.meta ? { meta: { ...message.meta } } : {}),
478
521
  }));
479
522
 
480
- setChats(prev => {
481
- const scopeChats = prev[scopeId] ?? [];
482
- const updatedChats = scopeChats.map(chat => {
483
- if (chat.session_id !== chatId) return chat;
484
- return { ...chat, messages: cloned };
485
- });
486
-
487
- persistChatsToLS(scopeId, updatedChats);
488
-
489
- return { ...prev, [scopeId]: updatedChats };
490
- });
523
+ setChats(prev =>
524
+ applySessionUpdate(prev, chatId, chat => ({
525
+ ...chat,
526
+ messages: cloned,
527
+ })),
528
+ );
491
529
  },
492
530
  [userSwitchKey],
493
531
  );
@@ -495,20 +533,12 @@ export function ChatProvider({
495
533
  const updateChatMeta = useCallback(
496
534
  (scopeId: string, chatId: string, patch: ChatMeta) => {
497
535
  if (userSwitchKey === null) return;
498
- setChats(prev => {
499
- const scopeChats = prev[scopeId] ?? [];
500
- const updatedChats = scopeChats.map(chat => {
501
- if (chat.session_id !== chatId) return chat;
502
- return {
503
- ...chat,
504
- meta: { ...chat.meta, ...patch },
505
- };
506
- });
507
-
508
- persistChatsToLS(scopeId, updatedChats);
509
-
510
- return { ...prev, [scopeId]: updatedChats };
511
- });
536
+ setChats(prev =>
537
+ applySessionUpdate(prev, chatId, chat => ({
538
+ ...chat,
539
+ meta: { ...chat.meta, ...patch },
540
+ })),
541
+ );
512
542
  },
513
543
  [userSwitchKey],
514
544
  );
@@ -563,19 +593,15 @@ export function ChatProvider({
563
593
  ? data.session_id
564
594
  : pendingChatSessionId;
565
595
 
566
- if (data.session_id && data.session_id !== pendingChatSessionId) {
567
- setChats(prev => {
568
- const scopeChats = prev[scopeId] ?? [];
569
- const updatedChats = scopeChats.map(chat =>
570
- chat.session_id === pendingChatSessionId
571
- ? { ...chat, session_id: data.session_id! }
572
- : chat,
573
- );
574
-
575
- persistChatsToLS(scopeId, updatedChats);
576
-
577
- return { ...prev, [scopeId]: updatedChats };
578
- });
596
+ if (
597
+ data.session_id &&
598
+ data.session_id !== pendingChatSessionId &&
599
+ userSwitchKey != null
600
+ ) {
601
+ renameSessionId(userSwitchKey, pendingChatSessionId, data.session_id);
602
+ setChats(prev =>
603
+ remapSessionIdInMemory(prev, pendingChatSessionId, data.session_id),
604
+ );
579
605
  setCurrentChatId(scopeId, data.session_id);
580
606
  }
581
607
 
@@ -621,6 +647,7 @@ export function ChatProvider({
621
647
  getCurrentChatId,
622
648
  sendChatMessageFn,
623
649
  setCurrentChatId,
650
+ userSwitchKey,
624
651
  ],
625
652
  );
626
653
 
@@ -633,7 +660,7 @@ export function ChatProvider({
633
660
  return;
634
661
  }
635
662
 
636
- const loaded = loadChatsFromLS(userSwitchKey);
663
+ const loaded = loadChatsFromSessionStorage(userSwitchKey);
637
664
  const updatedCurrentChatId = { ...loaded.currentChatId };
638
665
  for (const [scopeId, scopeChats] of Object.entries(loaded.chats)) {
639
666
  if (
@@ -653,6 +680,7 @@ export function ChatProvider({
653
680
  <ChatContext.Provider
654
681
  value={{
655
682
  newChat,
683
+ linkSessionToScope,
656
684
  setCurrentChatId,
657
685
  addMessage,
658
686
  removeMessageById,
@@ -742,6 +770,7 @@ export function useChatsForScopeId(scopeId: string) {
742
770
  getCurrentChatId,
743
771
  setCurrentChatId,
744
772
  newChat,
773
+ linkSessionToScope,
745
774
  addMessage,
746
775
  removeMessageById,
747
776
  updateMessageById,
@@ -761,6 +790,10 @@ export function useChatsForScopeId(scopeId: string) {
761
790
  currentChatId,
762
791
  isOutboundPending,
763
792
  setCurrentChatId: (targetId: string) => setCurrentChatId(scopeId, targetId),
793
+ linkSessionToScope: (
794
+ sessionId: string,
795
+ options?: LinkSessionToScopeOptions,
796
+ ) => linkSessionToScope(scopeId, sessionId, options),
764
797
  newChat: (options?: NewChatOptions) => newChat(scopeId, options),
765
798
  addMessage: (
766
799
  chatId: string,
@@ -19,6 +19,17 @@ describe('stripMessageForPersistence', () => {
19
19
  expect(message.text).toBe('Summary');
20
20
  });
21
21
 
22
+ it('strips from an unclosed json-dashboard fence', () => {
23
+ const message = stripMessageForPersistence({
24
+ id: '1b',
25
+ role: MessageRole.ASSISTANT,
26
+ text: 'Summary\n```json-dashboard\n{"tiles":[]}',
27
+ timestamp: 1,
28
+ });
29
+
30
+ expect(message.text).toBe('Summary');
31
+ });
32
+
22
33
  it('drops attachment content but keeps file metadata', () => {
23
34
  const message = stripMessageForPersistence({
24
35
  id: '2',