@sybilion/uilib 1.3.88 → 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 (31) 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/useChatPanelChromeModel.js +9 -7
  4. package/dist/esm/contexts/chat-context.js +180 -205
  5. package/dist/esm/contexts/chatPersistence.js +6 -18
  6. package/dist/esm/contexts/chatSessionStorage.js +245 -0
  7. package/dist/esm/lib/dashboard-spec/jsonDashboardFence.js +80 -0
  8. package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.d.ts +1 -5
  9. package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.d.ts +1 -0
  10. package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +6 -1
  11. package/dist/esm/types/src/contexts/chat-context.d.ts +6 -0
  12. package/dist/esm/types/src/contexts/chatPersistence.d.ts +4 -1
  13. package/dist/esm/types/src/contexts/chatSessionStorage.d.ts +32 -0
  14. package/dist/esm/types/src/contexts/chatSessionStorage.test.d.ts +1 -0
  15. package/dist/esm/types/src/lib/dashboard-spec/jsonDashboardFence.d.ts +9 -0
  16. package/dist/esm/types/src/lib/dashboard-spec/stripJsonDashboardFences.d.ts +1 -2
  17. package/dist/esm/types/src/lib/dashboard-spec/stripJsonDashboardFences.test.d.ts +1 -0
  18. package/package.json +1 -1
  19. package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.tsx +25 -0
  20. package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.tsx +35 -26
  21. package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +2 -1
  22. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +14 -6
  23. package/src/contexts/chat-context.tsx +253 -220
  24. package/src/contexts/chatPersistence.test.ts +11 -0
  25. package/src/contexts/chatPersistence.ts +22 -6
  26. package/src/contexts/chatSessionStorage.test.ts +125 -0
  27. package/src/contexts/chatSessionStorage.ts +321 -0
  28. package/src/lib/dashboard-spec/jsonDashboardFence.ts +98 -0
  29. package/src/lib/dashboard-spec/stripJsonDashboardFences.test.ts +84 -0
  30. package/src/lib/dashboard-spec/stripJsonDashboardFences.ts +5 -6
  31. package/dist/esm/lib/dashboard-spec/stripJsonDashboardFences.js +0 -7
@@ -4,27 +4,6 @@ import logger from '../../../../lib/logger.js';
4
4
  import S from './ChatMessage.styl.js';
5
5
  import S$1 from '../../InteractiveContent/InteractiveContent.styl.js';
6
6
 
7
- const injectHeaders = (content) => {
8
- // Match #, ##, ###, or #### headers at start of line
9
- const regex = /(^|\n)(#{1,4})\s+(.+?)(?=\n|$)/m;
10
- const matches = content.match(regex);
11
- if (!matches)
12
- return null;
13
- const level = matches[2].length;
14
- const headerText = matches[3].replace(/^\*+|\*+$/g, '');
15
- const Tag = level === 1 ? 'h1' : level === 2 ? 'h2' : level === 3 ? 'h3' : 'h4';
16
- // Calculate the actual match position and length
17
- // matches[0] includes the leading \n if present, but we want to replace from the # position
18
- const hasLeadingNewline = matches[1] === '\n';
19
- const startIndex = matches.index + (hasLeadingNewline ? 1 : 0);
20
- // Length is: # markers + space + header text (excluding leading newline)
21
- const length = matches[2].length + 1 + matches[3].length;
22
- return {
23
- elem: jsx(Tag, { children: headerText }),
24
- index: startIndex,
25
- length: length,
26
- };
27
- };
28
7
  /** Match sits inside list/table HTML (those blocks use dangerouslySetInnerHTML elsewhere). */
29
8
  const isInsideHtmlListOrTable = (content, matchStartIndex) => {
30
9
  const before = content.substring(0, matchStartIndex);
@@ -402,6 +381,35 @@ const injectAnchor = (content) => {
402
381
  length: matches[0].length,
403
382
  };
404
383
  };
384
+ const headerTextInjectors = [
385
+ injectAnchor,
386
+ injectMarkdownLink,
387
+ injectHTMLTags,
388
+ injectBold,
389
+ injectItalic,
390
+ injectAutolinkUrl,
391
+ ];
392
+ const injectHeaders = (content) => {
393
+ // Match #, ##, ###, or #### headers at start of line
394
+ const regex = /(^|\n)(#{1,4})\s+(.+?)(?=\n|$)/m;
395
+ const matches = content.match(regex);
396
+ if (!matches)
397
+ return null;
398
+ const level = matches[2].length;
399
+ const headerText = matches[3].replace(/^\*+|\*+$/g, '');
400
+ const Tag = level === 1 ? 'h1' : level === 2 ? 'h2' : level === 3 ? 'h3' : 'h4';
401
+ // Calculate the actual match position and length
402
+ // matches[0] includes the leading \n if present, but we want to replace from the # position
403
+ const hasLeadingNewline = matches[1] === '\n';
404
+ const startIndex = matches.index + (hasLeadingNewline ? 1 : 0);
405
+ // Length is: # markers + space + header text (excluding leading newline)
406
+ const length = matches[2].length + 1 + matches[3].length;
407
+ return {
408
+ elem: jsx(Tag, { children: runFormattingPipeline(headerText, headerTextInjectors) }),
409
+ index: startIndex,
410
+ length: length,
411
+ };
412
+ };
405
413
  const applyFormatting = (text) => runFormattingPipeline(text, [
406
414
  injectHeaders,
407
415
  injectAnchor,
@@ -4,6 +4,7 @@ import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.
4
4
  import 'lucide-react';
5
5
  import '../../InteractiveContent/InteractiveContent.styl.js';
6
6
  import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
7
+ import { stripJsonDashboardFences } from '../../../../lib/dashboard-spec/jsonDashboardFence.js';
7
8
  import { MessageRole } from '../Chat.types.js';
8
9
  import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments.js';
9
10
  import { AgentMessageContent } from './AgentMessageContent.js';
@@ -16,7 +17,7 @@ function ChatMessage({ role, text, inProgress, userTextFileAttachments, onQuickR
16
17
  });
17
18
  const isAssistant = role === MessageRole.ASSISTANT;
18
19
  const isSystem = role === MessageRole.SYSTEM;
19
- return (jsx("div", { className: cn(S.root, S[`role-${role}`], className), children: isSystem ? (jsx("div", { className: cn(S.text, textClassName), children: inProgress ? (jsx(TextShimmer, { as: "span", children: text })) : renderSystemMessage && message ? (renderSystemMessage(message)) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, textClassName: textClassName, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, quickReplyHidden: quickReplyHidden, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: cn(S.text, textClassName && S.textCustom, textClassName), children: jsx(InteractiveContent, { text: text }) }), fileAttachments.map(attachment => (jsx(UserTextFileAttachmentBubble, { attachment: attachment }, `${attachment.displayName}:${attachment.filename}`)))] })) }));
20
+ return (jsx("div", { className: cn(S.root, S[`role-${role}`], className), children: isSystem ? (jsx("div", { className: cn(S.text, textClassName), children: inProgress ? (jsx(TextShimmer, { as: "span", children: text })) : renderSystemMessage && message ? (renderSystemMessage(message)) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: stripJsonDashboardFences(text), textClassName: textClassName, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, quickReplyHidden: quickReplyHidden, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: cn(S.text, textClassName && S.textCustom, textClassName), children: jsx(InteractiveContent, { text: text }) }), fileAttachments.map(attachment => (jsx(UserTextFileAttachmentBubble, { attachment: attachment }, `${attachment.displayName}:${attachment.filename}`)))] })) }));
20
21
  }
21
22
 
22
23
  export { ChatMessage };
@@ -22,7 +22,7 @@ const CHAT_NEW_SHORTCUT_KEY = 'o';
22
22
  const CHAT_QUERY_PARAM = 'chat';
23
23
  const CHAT_OPEN_VALUE = 'open';
24
24
  const PROMPT_QUERY_PARAM = 'prompt';
25
- function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, renderSystemMessage, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, copyHistoryOnNewChat = false, transformSendPayload, }) {
25
+ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, renderSystemMessage, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, copyHistoryOnNewChat = false, transformSendPayload, submitPresetsViaApi = false, }) {
26
26
  const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
27
27
  const isMobile = useIsMobile();
28
28
  const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, sidebarWidthPx, chatWidthPx, setChatWidthPx, getShellWidth, chatPanelOpen: shellChatPanelOpen, setChatPanelOpen, } = useSidebar();
@@ -350,7 +350,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
350
350
  setOutboundLoadingLabel(loadingLabelFromSendPayload(payload));
351
351
  try {
352
352
  const { response: assistantResponse, sessionId } = await sendMessage(payload);
353
- onMessage?.(displayTextFromSendPayload(payload), assistantResponse, sessionId);
353
+ await onMessage?.(displayTextFromSendPayload(payload), assistantResponse, sessionId);
354
354
  }
355
355
  finally {
356
356
  setOutboundLoadingLabel(undefined);
@@ -488,7 +488,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
488
488
  setOutboundLoadingLabel(loadingLabelFromSendPayload(payload));
489
489
  try {
490
490
  const { response: assistantResponse, sessionId } = await sendMessage(payload);
491
- onMessage?.(displayTextFromSendPayload(payload), assistantResponse, sessionId);
491
+ await onMessage?.(displayTextFromSendPayload(payload), assistantResponse, sessionId);
492
492
  }
493
493
  finally {
494
494
  setOutboundLoadingLabel(undefined);
@@ -523,10 +523,11 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
523
523
  const scriptGraph = isPresetScriptGraph(script);
524
524
  const hasLinearScript = Array.isArray(script) && script.length > 0;
525
525
  const hasReplies = preset.replies && Object.keys(preset.replies).length > 0;
526
- const isLocalDemo = hasLinearScript ||
527
- scriptGraph ||
528
- Boolean(preset.answer?.trim()) ||
529
- Boolean(hasReplies);
526
+ const isLocalDemo = !submitPresetsViaApi &&
527
+ (hasLinearScript ||
528
+ scriptGraph ||
529
+ Boolean(preset.answer?.trim()) ||
530
+ Boolean(hasReplies));
530
531
  if (!isLocalDemo) {
531
532
  if (!currentChatId)
532
533
  return;
@@ -617,6 +618,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
617
618
  handlePromptSubmit,
618
619
  addMessage,
619
620
  presetsWithFreeform,
621
+ submitPresetsViaApi,
620
622
  ]);
621
623
  const resolvedEmptyState = useMemo(() => {
622
624
  if (!emptyState)
@@ -2,84 +2,42 @@ import { jsx } from 'react/jsx-runtime';
2
2
  import { createContext, useState, useCallback, useEffect, useContext, useMemo } from 'react';
3
3
  import { MessageRole } from '../components/ui/Chat/Chat.types.js';
4
4
  import { normalizeUserTextFileAttachments } from '../components/ui/Chat/buildChatSendMessagePayload.js';
5
- import { stripJsonDashboardFences } from '../lib/dashboard-spec/stripJsonDashboardFences.js';
6
- import { LS } from '@homecode/ui';
7
- import { persistChatsToLS, safeLsSet } from './chatPersistence.js';
5
+ import { normalizeJsonDashboardChatText, stripJsonDashboardFences } from '../lib/dashboard-spec/jsonDashboardFence.js';
6
+ import { isEphemeralChatScope } from './chatPersistence.js';
7
+ import { loadChatsFromSessionStorage, loadScopeIndex, persistScopeIndex, linkSessionToScope, loadSession, persistSession, addScopeIdToRegistry, removeSessionFromScope, countSessionReferences, deleteSessionBlob, renameSessionId } from './chatSessionStorage.js';
8
8
 
9
- const CHATS_PREFIX = 'chats-';
10
- const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
11
9
  const ChatContext = createContext(undefined);
12
10
  /** Stable composite key; avoids collisions if `scopeId` contains `:`. */
13
11
  function outboundPendingKey(scopeId, chatSessionId) {
14
12
  return `${scopeId}\0${chatSessionId}`;
15
13
  }
16
- function getCurrentChatIdKey(scopeId) {
17
- return `chat-current-id-${scopeId}`;
18
- }
19
- function getChatsKey(scopeId) {
20
- return `${CHATS_PREFIX}${scopeId}`;
21
- }
22
- function scopeIdsForUser(userId, all) {
23
- const prefix = `${userId}-`;
24
- const out = new Set();
25
- for (const id of all) {
26
- if (id.startsWith(prefix))
27
- out.add(id);
28
- }
29
- return out;
30
- }
31
- function discoverScopeIdsFromLS() {
32
- const scopeIds = new Set();
33
- const registryRaw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
34
- const registry = Array.isArray(registryRaw) ? registryRaw : [];
35
- registry.forEach((id) => {
36
- if (typeof id === 'string' && id)
37
- scopeIds.add(id);
38
- });
39
- try {
40
- for (let i = 0; i < window.localStorage.length; i++) {
41
- const key = window.localStorage.key(i);
42
- if (key?.startsWith(CHATS_PREFIX)) {
43
- const scopeId = key.slice(CHATS_PREFIX.length);
44
- if (scopeId)
45
- scopeIds.add(scopeId);
46
- }
14
+ function applySessionUpdate(prev, sessionId, updater) {
15
+ let updatedChat = null;
16
+ const next = {};
17
+ for (const [scopeId, scopeChats] of Object.entries(prev)) {
18
+ const idx = scopeChats.findIndex(chat => chat.session_id === sessionId);
19
+ if (idx === -1) {
20
+ next[scopeId] = scopeChats;
21
+ continue;
47
22
  }
23
+ const chat = updater(scopeChats[idx]);
24
+ updatedChat = chat;
25
+ const scopeNext = [...scopeChats];
26
+ scopeNext[idx] = chat;
27
+ next[scopeId] = scopeNext;
48
28
  }
49
- catch {
50
- // ignore
51
- }
52
- return scopeIds;
53
- }
54
- function loadChatsFromLS(userId) {
55
- if (typeof window === 'undefined' || userId === null) {
56
- return { chats: {}, currentChatId: {} };
57
- }
58
- const chats = {};
59
- const currentChatId = {};
60
- const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
61
- for (const scopeId of scopeIds) {
62
- const chatsKey = getChatsKey(scopeId);
63
- const storedRaw = LS.get(chatsKey);
64
- const stored = Array.isArray(storedRaw) ? storedRaw : undefined;
65
- if (stored?.length) {
66
- chats[scopeId] = stored;
67
- const ck = getCurrentChatIdKey(scopeId);
68
- const savedCurrentIdRaw = LS.get(ck);
69
- const savedCurrentId = typeof savedCurrentIdRaw === 'string' && savedCurrentIdRaw !== ''
70
- ? savedCurrentIdRaw
71
- : undefined;
72
- currentChatId[scopeId] = savedCurrentId ?? stored[0]?.session_id ?? null;
73
- }
74
- }
75
- return { chats, currentChatId };
29
+ if (updatedChat)
30
+ persistSession(updatedChat);
31
+ return next;
76
32
  }
77
- function addScopeIdToRegistry(scopeId) {
78
- const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
79
- const registry = Array.isArray(raw) ? [...raw] : [];
80
- if (!registry.includes(scopeId)) {
81
- safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
33
+ function remapSessionIdInMemory(prev, oldSessionId, newSessionId) {
34
+ const next = {};
35
+ for (const [scopeId, scopeChats] of Object.entries(prev)) {
36
+ next[scopeId] = scopeChats.map(chat => chat.session_id === oldSessionId
37
+ ? { ...chat, session_id: newSessionId }
38
+ : chat);
82
39
  }
40
+ return next;
83
41
  }
84
42
  /** Shallow-clone messages for seeding another session; drops in-progress rows. */
85
43
  function cloneMessagesForNewSession(messages) {
@@ -87,6 +45,7 @@ function cloneMessagesForNewSession(messages) {
87
45
  .filter(message => !message.inProgress)
88
46
  .map(message => ({
89
47
  ...message,
48
+ text: stripJsonDashboardFences(message.text),
90
49
  ...(message.meta ? { meta: { ...message.meta } } : {}),
91
50
  ...(message.userTextFileAttachments
92
51
  ? {
@@ -97,14 +56,14 @@ function cloneMessagesForNewSession(messages) {
97
56
  }
98
57
  function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessageFn, }) {
99
58
  const [chats, setChats] = useState(() => {
100
- if (userSwitchKey === null)
59
+ if (userSwitchKey === null || typeof window === 'undefined')
101
60
  return {};
102
- return loadChatsFromLS(userSwitchKey).chats;
61
+ return loadChatsFromSessionStorage(userSwitchKey).chats;
103
62
  });
104
63
  const [currentChatId, setCurrentChatIdState] = useState(() => {
105
- if (userSwitchKey === null)
64
+ if (userSwitchKey === null || typeof window === 'undefined')
106
65
  return {};
107
- return loadChatsFromLS(userSwitchKey).currentChatId;
66
+ return loadChatsFromSessionStorage(userSwitchKey).currentChatId;
108
67
  });
109
68
  const [outboundPendingByKey, setOutboundPendingByKey] = useState({});
110
69
  const [panelBusyCount, setPanelBusyCount] = useState(0);
@@ -140,6 +99,54 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
140
99
  return null;
141
100
  return v;
142
101
  }, [currentChatId]);
102
+ const setCurrentChatId = useCallback((currScopeId, sessionId) => {
103
+ if (!sessionId)
104
+ return;
105
+ setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
106
+ const index = loadScopeIndex(currScopeId);
107
+ if (index) {
108
+ persistScopeIndex(currScopeId, {
109
+ ...index,
110
+ currentSessionId: sessionId,
111
+ });
112
+ }
113
+ }, []);
114
+ const linkSessionToScope$1 = useCallback((scopeId, sessionId, options) => {
115
+ if (userSwitchKey === null)
116
+ return;
117
+ linkSessionToScope(scopeId, sessionId, options);
118
+ setChats(prev => {
119
+ const sessionFromLs = loadSession(sessionId);
120
+ let sessionFromMemory = null;
121
+ for (const scopeChats of Object.values(prev)) {
122
+ const found = scopeChats.find(chat => chat.session_id === sessionId);
123
+ if (found &&
124
+ (!sessionFromMemory ||
125
+ found.messages.length > sessionFromMemory.messages.length)) {
126
+ sessionFromMemory = found;
127
+ }
128
+ }
129
+ const session = sessionFromMemory &&
130
+ (!sessionFromLs ||
131
+ sessionFromMemory.messages.length >= sessionFromLs.messages.length)
132
+ ? sessionFromMemory
133
+ : sessionFromLs;
134
+ if (!session)
135
+ return prev;
136
+ if (session === sessionFromMemory) {
137
+ persistSession(sessionFromMemory);
138
+ }
139
+ const scopeChats = prev[scopeId] ?? [];
140
+ const exists = scopeChats.some(chat => chat.session_id === sessionId);
141
+ const updatedChats = exists
142
+ ? scopeChats.map(chat => chat.session_id === sessionId ? session : chat)
143
+ : [session, ...scopeChats];
144
+ return { ...prev, [scopeId]: updatedChats };
145
+ });
146
+ if (options?.makeCurrent !== false) {
147
+ setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
148
+ }
149
+ }, [userSwitchKey]);
143
150
  const newChat = useCallback((scopeId, options) => {
144
151
  if (userSwitchKey === null)
145
152
  return undefined;
@@ -148,63 +155,65 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
148
155
  const seededMessages = options?.seedMessages && options.seedMessages.length > 0
149
156
  ? cloneMessagesForNewSession(options.seedMessages)
150
157
  : [];
151
- const newChat = {
158
+ const created = {
152
159
  session_id: sessionId,
153
160
  name: '',
154
161
  messages: seededMessages,
155
162
  };
163
+ persistSession(created);
164
+ linkSessionToScope(scopeId, sessionId, { makeCurrent: true });
156
165
  setChats(prev => {
157
- const currentChats = prev[scopeId] ?? [];
158
- const updatedChats = [newChat, ...currentChats];
159
- persistChatsToLS(scopeId, updatedChats);
160
- return {
161
- ...prev,
162
- [scopeId]: updatedChats,
163
- };
166
+ const scopeChats = prev[scopeId] ?? [];
167
+ const isEphemeral = isEphemeralChatScope(scopeId);
168
+ if (isEphemeral) {
169
+ for (const old of scopeChats) {
170
+ if (old.session_id !== sessionId) {
171
+ removeSessionFromScope(scopeId, old.session_id);
172
+ if (countSessionReferences(userSwitchKey, old.session_id) === 0) {
173
+ deleteSessionBlob(old.session_id);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ const updatedChats = isEphemeral ? [created] : [created, ...scopeChats];
179
+ return { ...prev, [scopeId]: updatedChats };
164
180
  });
165
181
  setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
166
- safeLsSet(getCurrentChatIdKey(scopeId), sessionId);
167
182
  return sessionId;
168
183
  }, [userSwitchKey]);
169
- const setCurrentChatId = useCallback((currScopeId, sessionId) => {
170
- if (!sessionId)
171
- return;
172
- setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
173
- safeLsSet(getCurrentChatIdKey(currScopeId), sessionId);
174
- }, []);
175
184
  const deleteChat = useCallback((scopeId, sessionId) => {
176
- const currentKey = getCurrentChatIdKey(scopeId);
185
+ const scopeChats = chats[scopeId] ?? [];
186
+ const deletedIndex = scopeChats.findIndex(chat => chat.session_id === sessionId);
187
+ if (deletedIndex === -1)
188
+ return;
189
+ removeSessionFromScope(scopeId, sessionId);
190
+ if (userSwitchKey != null &&
191
+ countSessionReferences(userSwitchKey, sessionId) === 0) {
192
+ deleteSessionBlob(sessionId);
193
+ }
177
194
  setChats(prev => {
178
- const scopeChats = prev[scopeId] ?? [];
179
- const deletedIndex = scopeChats.findIndex(c => c.session_id === sessionId);
180
- if (deletedIndex === -1) {
181
- return prev;
182
- }
183
- const updatedChats = scopeChats.filter(c => c.session_id !== sessionId);
184
- persistChatsToLS(scopeId, updatedChats);
185
- setCurrentChatIdState(prevCurr => {
186
- if (prevCurr[scopeId] !== sessionId) {
187
- return prevCurr;
188
- }
189
- const nextId = deletedIndex > 0
190
- ? scopeChats[deletedIndex - 1].session_id
191
- : (updatedChats[0]?.session_id ?? null);
192
- if (nextId) {
193
- safeLsSet(currentKey, nextId);
194
- }
195
- else {
196
- LS.remove(currentKey);
197
- }
198
- return { ...prevCurr, [scopeId]: nextId };
199
- });
200
- return { ...prev, [scopeId]: updatedChats };
195
+ const currentScopeChats = prev[scopeId] ?? [];
196
+ return {
197
+ ...prev,
198
+ [scopeId]: currentScopeChats.filter(chat => chat.session_id !== sessionId),
199
+ };
201
200
  });
202
- }, []);
201
+ setCurrentChatIdState(prevCurr => {
202
+ if (prevCurr[scopeId] !== sessionId)
203
+ return prevCurr;
204
+ const index = loadScopeIndex(scopeId);
205
+ const nextId = index?.currentSessionId ??
206
+ (deletedIndex > 0 ? scopeChats[deletedIndex - 1].session_id : null);
207
+ return { ...prevCurr, [scopeId]: nextId };
208
+ });
209
+ }, [chats, userSwitchKey]);
203
210
  const addMessage = useCallback((scopeId, chatId, role, text, options) => {
204
211
  if (userSwitchKey === null)
205
212
  return undefined;
206
213
  addScopeIdToRegistry(scopeId);
207
- const storedText = stripJsonDashboardFences(text);
214
+ const storedText = role === MessageRole.ASSISTANT
215
+ ? normalizeJsonDashboardChatText(text)
216
+ : stripJsonDashboardFences(text);
208
217
  const attachments = role === MessageRole.USER
209
218
  ? options?.userTextFileAttachments
210
219
  : undefined;
@@ -219,73 +228,52 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
219
228
  ...(options?.inProgress ? { inProgress: true } : {}),
220
229
  ...(options?.meta ? { meta: { ...options.meta } } : {}),
221
230
  };
222
- setChats(prev => {
223
- const scopeChats = prev[scopeId] ?? [];
224
- const updatedChats = scopeChats.map(chat => {
225
- if (chat.session_id === chatId) {
226
- return { ...chat, messages: [...chat.messages, newMessage] };
227
- }
228
- return chat;
229
- });
230
- persistChatsToLS(scopeId, updatedChats);
231
- return { ...prev, [scopeId]: updatedChats };
232
- });
231
+ setChats(prev => applySessionUpdate(prev, chatId, chat => ({
232
+ ...chat,
233
+ messages: [...chat.messages, newMessage],
234
+ })));
233
235
  return newMessage.id;
234
236
  }, [userSwitchKey]);
235
237
  const removeMessageById = useCallback((scopeId, chatId, messageId) => {
236
238
  if (userSwitchKey === null)
237
239
  return;
238
- setChats(prev => {
239
- const scopeChats = prev[scopeId] ?? [];
240
- const updatedChats = scopeChats.map(chat => {
241
- if (chat.session_id !== chatId)
242
- return chat;
243
- return {
244
- ...chat,
245
- messages: chat.messages.filter(m => m.id !== messageId),
246
- };
247
- });
248
- persistChatsToLS(scopeId, updatedChats);
249
- return { ...prev, [scopeId]: updatedChats };
250
- });
240
+ setChats(prev => applySessionUpdate(prev, chatId, chat => ({
241
+ ...chat,
242
+ messages: chat.messages.filter(message => message.id !== messageId),
243
+ })));
251
244
  }, [userSwitchKey]);
252
245
  const updateMessageById = useCallback((scopeId, chatId, messageId, patch) => {
253
246
  if (userSwitchKey === null)
254
247
  return;
255
- setChats(prev => {
256
- const scopeChats = prev[scopeId] ?? [];
257
- const updatedChats = scopeChats.map(chat => {
258
- if (chat.session_id !== chatId)
259
- return chat;
260
- return {
261
- ...chat,
262
- messages: chat.messages.map(message => {
263
- if (message.id !== messageId)
264
- return message;
265
- const next = { ...message };
266
- if (patch.role != null) {
267
- next.role = patch.role;
268
- }
269
- if (patch.text != null) {
270
- next.text = stripJsonDashboardFences(patch.text);
271
- }
272
- if (patch.inProgress === true) {
273
- next.inProgress = true;
274
- }
275
- else if (patch.inProgress === false ||
276
- (patch.role != null && patch.role !== MessageRole.SYSTEM)) {
277
- delete next.inProgress;
278
- }
279
- if (patch.meta != null) {
280
- next.meta = { ...next.meta, ...patch.meta };
281
- }
282
- return next;
283
- }),
284
- };
285
- });
286
- persistChatsToLS(scopeId, updatedChats);
287
- return { ...prev, [scopeId]: updatedChats };
288
- });
248
+ setChats(prev => applySessionUpdate(prev, chatId, chat => ({
249
+ ...chat,
250
+ messages: chat.messages.map(message => {
251
+ if (message.id !== messageId)
252
+ return message;
253
+ const next = { ...message };
254
+ if (patch.role != null) {
255
+ next.role = patch.role;
256
+ }
257
+ if (patch.text != null) {
258
+ const targetRole = patch.role ?? message.role;
259
+ next.text =
260
+ targetRole === MessageRole.ASSISTANT
261
+ ? normalizeJsonDashboardChatText(patch.text)
262
+ : stripJsonDashboardFences(patch.text);
263
+ }
264
+ if (patch.inProgress === true) {
265
+ next.inProgress = true;
266
+ }
267
+ else if (patch.inProgress === false ||
268
+ (patch.role != null && patch.role !== MessageRole.SYSTEM)) {
269
+ delete next.inProgress;
270
+ }
271
+ if (patch.meta != null) {
272
+ next.meta = { ...next.meta, ...patch.meta };
273
+ }
274
+ return next;
275
+ }),
276
+ })));
289
277
  }, [userSwitchKey]);
290
278
  const setChatMessages = useCallback((scopeId, chatId, messages) => {
291
279
  if (userSwitchKey === null)
@@ -293,35 +281,23 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
293
281
  addScopeIdToRegistry(scopeId);
294
282
  const cloned = messages.map(message => ({
295
283
  ...message,
284
+ text: message.role === MessageRole.ASSISTANT
285
+ ? message.text
286
+ : stripJsonDashboardFences(message.text),
296
287
  ...(message.meta ? { meta: { ...message.meta } } : {}),
297
288
  }));
298
- setChats(prev => {
299
- const scopeChats = prev[scopeId] ?? [];
300
- const updatedChats = scopeChats.map(chat => {
301
- if (chat.session_id !== chatId)
302
- return chat;
303
- return { ...chat, messages: cloned };
304
- });
305
- persistChatsToLS(scopeId, updatedChats);
306
- return { ...prev, [scopeId]: updatedChats };
307
- });
289
+ setChats(prev => applySessionUpdate(prev, chatId, chat => ({
290
+ ...chat,
291
+ messages: cloned,
292
+ })));
308
293
  }, [userSwitchKey]);
309
294
  const updateChatMeta = useCallback((scopeId, chatId, patch) => {
310
295
  if (userSwitchKey === null)
311
296
  return;
312
- setChats(prev => {
313
- const scopeChats = prev[scopeId] ?? [];
314
- const updatedChats = scopeChats.map(chat => {
315
- if (chat.session_id !== chatId)
316
- return chat;
317
- return {
318
- ...chat,
319
- meta: { ...chat.meta, ...patch },
320
- };
321
- });
322
- persistChatsToLS(scopeId, updatedChats);
323
- return { ...prev, [scopeId]: updatedChats };
324
- });
297
+ setChats(prev => applySessionUpdate(prev, chatId, chat => ({
298
+ ...chat,
299
+ meta: { ...chat.meta, ...patch },
300
+ })));
325
301
  }, [userSwitchKey]);
326
302
  const sendMessage = useCallback(async (scopeId, message, chatId) => {
327
303
  const targetChatId = chatId ?? getCurrentChatId(scopeId);
@@ -350,15 +326,11 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
350
326
  data.session_id && data.session_id !== pendingChatSessionId
351
327
  ? data.session_id
352
328
  : pendingChatSessionId;
353
- if (data.session_id && data.session_id !== pendingChatSessionId) {
354
- setChats(prev => {
355
- const scopeChats = prev[scopeId] ?? [];
356
- const updatedChats = scopeChats.map(chat => chat.session_id === pendingChatSessionId
357
- ? { ...chat, session_id: data.session_id }
358
- : chat);
359
- persistChatsToLS(scopeId, updatedChats);
360
- return { ...prev, [scopeId]: updatedChats };
361
- });
329
+ if (data.session_id &&
330
+ data.session_id !== pendingChatSessionId &&
331
+ userSwitchKey != null) {
332
+ renameSessionId(userSwitchKey, pendingChatSessionId, data.session_id);
333
+ setChats(prev => remapSessionIdInMemory(prev, pendingChatSessionId, data.session_id));
362
334
  setCurrentChatId(scopeId, data.session_id);
363
335
  }
364
336
  if (progressMessageId) {
@@ -389,6 +361,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
389
361
  getCurrentChatId,
390
362
  sendChatMessageFn,
391
363
  setCurrentChatId,
364
+ userSwitchKey,
392
365
  ]);
393
366
  useEffect(() => {
394
367
  if (userSwitchKey === null) {
@@ -398,7 +371,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
398
371
  setPanelBusyCount(0);
399
372
  return;
400
373
  }
401
- const loaded = loadChatsFromLS(userSwitchKey);
374
+ const loaded = loadChatsFromSessionStorage(userSwitchKey);
402
375
  const updatedCurrentChatId = { ...loaded.currentChatId };
403
376
  for (const [scopeId, scopeChats] of Object.entries(loaded.chats)) {
404
377
  if (scopeChats.length > 0 &&
@@ -412,6 +385,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
412
385
  }, [userSwitchKey]);
413
386
  return (jsx(ChatContext.Provider, { value: {
414
387
  newChat,
388
+ linkSessionToScope: linkSessionToScope$1,
415
389
  setCurrentChatId,
416
390
  addMessage,
417
391
  removeMessageById,
@@ -478,7 +452,7 @@ function useSyncChatPanelBusy(isLoading) {
478
452
  }, [isLoading, acquirePanelBusy, releasePanelBusy]);
479
453
  }
480
454
  function useChatsForScopeId(scopeId) {
481
- const { getChatsForScopeId, getCurrentChatId, setCurrentChatId, newChat, addMessage, removeMessageById, updateMessageById, updateChatMeta, setChatMessages, sendMessage, deleteChat, } = useChats();
455
+ const { getChatsForScopeId, getCurrentChatId, setCurrentChatId, newChat, linkSessionToScope, addMessage, removeMessageById, updateMessageById, updateChatMeta, setChatMessages, sendMessage, deleteChat, } = useChats();
482
456
  const chats = getChatsForScopeId(scopeId);
483
457
  const currentChatId = getCurrentChatId(scopeId);
484
458
  const currentChat = useChat(scopeId, currentChatId ?? undefined);
@@ -489,6 +463,7 @@ function useChatsForScopeId(scopeId) {
489
463
  currentChatId,
490
464
  isOutboundPending,
491
465
  setCurrentChatId: (targetId) => setCurrentChatId(scopeId, targetId),
466
+ linkSessionToScope: (sessionId, options) => linkSessionToScope(scopeId, sessionId, options),
492
467
  newChat: (options) => newChat(scopeId, options),
493
468
  addMessage: (chatId, role, text, options) => addMessage(scopeId, chatId, role, text, options),
494
469
  removeMessageById: (chatId, messageId) => removeMessageById(scopeId, chatId, messageId),