@sybilion/uilib 1.3.88 → 1.3.90

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 +4 -1
  3. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +17 -10
  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 +7 -1
  22. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +21 -8
  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
@@ -0,0 +1,245 @@
1
+ import { LS } from '@homecode/ui';
2
+ import { stripChatsForPersistence, safeLsSet, isEphemeralChatScope } from './chatPersistence.js';
3
+
4
+ const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
5
+ const SESSION_PREFIX = 'chat-session-';
6
+ const SCOPE_PREFIX = 'chat-scope-';
7
+ const CHATS_PREFIX = 'chats-';
8
+ const CHAT_CURRENT_ID_PREFIX = 'chat-current-id-';
9
+ function getSessionKey(sessionId) {
10
+ return `${SESSION_PREFIX}${sessionId}`;
11
+ }
12
+ function getScopeKey(scopeId) {
13
+ return `${SCOPE_PREFIX}${scopeId}`;
14
+ }
15
+ function getLegacyChatsKey(scopeId) {
16
+ return `${CHATS_PREFIX}${scopeId}`;
17
+ }
18
+ function getLegacyCurrentIdKey(scopeId) {
19
+ return `${CHAT_CURRENT_ID_PREFIX}${scopeId}`;
20
+ }
21
+ function isRecord(value) {
22
+ return typeof value === 'object' && value !== null;
23
+ }
24
+ function isChat(value) {
25
+ if (!isRecord(value))
26
+ return false;
27
+ return (typeof value.session_id === 'string' &&
28
+ typeof value.name === 'string' &&
29
+ Array.isArray(value.messages));
30
+ }
31
+ function isChatScopeIndex(value) {
32
+ if (!isRecord(value))
33
+ return false;
34
+ const { currentSessionId, sessionIds } = value;
35
+ if (currentSessionId !== null && typeof currentSessionId !== 'string') {
36
+ return false;
37
+ }
38
+ if (!Array.isArray(sessionIds))
39
+ return false;
40
+ return sessionIds.every(id => typeof id === 'string');
41
+ }
42
+ function trimScopeIndexForPersistence(scopeId, index) {
43
+ if (!isEphemeralChatScope(scopeId))
44
+ return index;
45
+ const current = index.currentSessionId ?? index.sessionIds[0] ?? null;
46
+ if (!current)
47
+ return { currentSessionId: null, sessionIds: [] };
48
+ return { currentSessionId: current, sessionIds: [current] };
49
+ }
50
+ function mergeSessionChat(existing, incoming) {
51
+ if (!existing)
52
+ return incoming;
53
+ return incoming.messages.length >= existing.messages.length
54
+ ? incoming
55
+ : existing;
56
+ }
57
+ function loadSession(sessionId) {
58
+ const raw = LS.get(getSessionKey(sessionId));
59
+ return isChat(raw) ? raw : null;
60
+ }
61
+ function persistSession(chat) {
62
+ const [stripped] = stripChatsForPersistence([chat]);
63
+ safeLsSet(getSessionKey(chat.session_id), stripped);
64
+ }
65
+ function deleteSessionBlob(sessionId) {
66
+ LS.remove(getSessionKey(sessionId));
67
+ }
68
+ function loadScopeIndex(scopeId) {
69
+ const raw = LS.get(getScopeKey(scopeId));
70
+ return isChatScopeIndex(raw) ? raw : null;
71
+ }
72
+ function persistScopeIndex(scopeId, index) {
73
+ safeLsSet(getScopeKey(scopeId), trimScopeIndexForPersistence(scopeId, index));
74
+ }
75
+ function addScopeIdToRegistry(scopeId) {
76
+ const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
77
+ const registry = Array.isArray(raw) ? [...raw] : [];
78
+ if (!registry.includes(scopeId)) {
79
+ safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
80
+ }
81
+ }
82
+ function discoverScopeIdsFromLS() {
83
+ const scopeIds = new Set();
84
+ const registryRaw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
85
+ const registry = Array.isArray(registryRaw) ? registryRaw : [];
86
+ registry.forEach((id) => {
87
+ if (typeof id === 'string' && id)
88
+ scopeIds.add(id);
89
+ });
90
+ try {
91
+ for (let i = 0; i < window.localStorage.length; i++) {
92
+ const key = window.localStorage.key(i);
93
+ if (!key)
94
+ continue;
95
+ if (key.startsWith(SCOPE_PREFIX)) {
96
+ const scopeId = key.slice(SCOPE_PREFIX.length);
97
+ if (scopeId)
98
+ scopeIds.add(scopeId);
99
+ }
100
+ else if (key.startsWith(CHATS_PREFIX)) {
101
+ const scopeId = key.slice(CHATS_PREFIX.length);
102
+ if (scopeId)
103
+ scopeIds.add(scopeId);
104
+ }
105
+ }
106
+ }
107
+ catch {
108
+ // ignore
109
+ }
110
+ return scopeIds;
111
+ }
112
+ function scopeIdsForUser(userId, all) {
113
+ const prefix = `${userId}-`;
114
+ const out = new Set();
115
+ for (const id of all) {
116
+ if (id.startsWith(prefix))
117
+ out.add(id);
118
+ }
119
+ return out;
120
+ }
121
+ function hasLegacyScopeData(scopeId) {
122
+ const legacyRaw = LS.get(getLegacyChatsKey(scopeId));
123
+ return Array.isArray(legacyRaw) && legacyRaw.length > 0;
124
+ }
125
+ function migrateLegacyScope(scopeId) {
126
+ const legacyRaw = LS.get(getLegacyChatsKey(scopeId));
127
+ const legacyChats = Array.isArray(legacyRaw) ? legacyRaw : [];
128
+ const chats = [];
129
+ legacyChats.forEach(item => {
130
+ if (isChat(item))
131
+ chats.push(item);
132
+ });
133
+ if (chats.length === 0)
134
+ return loadScopeIndex(scopeId);
135
+ const savedCurrentRaw = LS.get(getLegacyCurrentIdKey(scopeId));
136
+ const savedCurrentId = typeof savedCurrentRaw === 'string' && savedCurrentRaw !== ''
137
+ ? savedCurrentRaw
138
+ : undefined;
139
+ const currentSessionId = savedCurrentId ?? chats[0]?.session_id ?? null;
140
+ const sessionIds = chats.map(chat => chat.session_id);
141
+ chats.forEach(chat => {
142
+ const existing = loadSession(chat.session_id);
143
+ persistSession(mergeSessionChat(existing, chat));
144
+ });
145
+ const index = { currentSessionId, sessionIds };
146
+ persistScopeIndex(scopeId, index);
147
+ addScopeIdToRegistry(scopeId);
148
+ LS.remove(getLegacyChatsKey(scopeId));
149
+ LS.remove(getLegacyCurrentIdKey(scopeId));
150
+ return index;
151
+ }
152
+ function linkSessionToScope(scopeId, sessionId, options) {
153
+ addScopeIdToRegistry(scopeId);
154
+ const existing = loadScopeIndex(scopeId) ?? {
155
+ currentSessionId: null,
156
+ sessionIds: [],
157
+ };
158
+ const sessionIds = existing.sessionIds.includes(sessionId)
159
+ ? existing.sessionIds
160
+ : [sessionId, ...existing.sessionIds];
161
+ const makeCurrent = options?.makeCurrent !== false;
162
+ const index = {
163
+ sessionIds,
164
+ currentSessionId: makeCurrent ? sessionId : existing.currentSessionId,
165
+ };
166
+ persistScopeIndex(scopeId, index);
167
+ return index;
168
+ }
169
+ function hydrateChatsForScope(scopeId) {
170
+ const index = loadScopeIndex(scopeId);
171
+ if (!index)
172
+ return [];
173
+ return index.sessionIds
174
+ .map(sessionId => loadSession(sessionId))
175
+ .filter((chat) => chat != null);
176
+ }
177
+ function countSessionReferences(userId, sessionId) {
178
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
179
+ let count = 0;
180
+ for (const scopeId of scopeIds) {
181
+ const index = loadScopeIndex(scopeId);
182
+ if (index?.sessionIds.includes(sessionId))
183
+ count += 1;
184
+ }
185
+ return count;
186
+ }
187
+ function removeSessionFromScope(scopeId, sessionId) {
188
+ const existing = loadScopeIndex(scopeId);
189
+ if (!existing)
190
+ return null;
191
+ const sessionIds = existing.sessionIds.filter(id => id !== sessionId);
192
+ if (sessionIds.length === existing.sessionIds.length)
193
+ return existing;
194
+ let nextCurrent = existing.currentSessionId;
195
+ if (nextCurrent === sessionId) {
196
+ const deletedIndex = existing.sessionIds.indexOf(sessionId);
197
+ nextCurrent =
198
+ deletedIndex > 0
199
+ ? existing.sessionIds[deletedIndex - 1]
200
+ : (sessionIds[0] ?? null);
201
+ }
202
+ const index = {
203
+ currentSessionId: nextCurrent,
204
+ sessionIds,
205
+ };
206
+ persistScopeIndex(scopeId, index);
207
+ return index;
208
+ }
209
+ function renameSessionId(userId, oldId, newId) {
210
+ if (oldId === newId)
211
+ return;
212
+ const chat = loadSession(oldId);
213
+ if (!chat)
214
+ return;
215
+ persistSession({ ...chat, session_id: newId });
216
+ LS.remove(getSessionKey(oldId));
217
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
218
+ for (const scopeId of scopeIds) {
219
+ const index = loadScopeIndex(scopeId);
220
+ if (!index?.sessionIds.includes(oldId))
221
+ continue;
222
+ const sessionIds = index.sessionIds.map(id => (id === oldId ? newId : id));
223
+ const currentSessionId = index.currentSessionId === oldId ? newId : index.currentSessionId;
224
+ persistScopeIndex(scopeId, { currentSessionId, sessionIds });
225
+ }
226
+ }
227
+ function loadChatsFromSessionStorage(userId) {
228
+ const chats = {};
229
+ const currentChatId = {};
230
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
231
+ for (const scopeId of scopeIds) {
232
+ if (hasLegacyScopeData(scopeId)) {
233
+ migrateLegacyScope(scopeId);
234
+ }
235
+ const index = loadScopeIndex(scopeId);
236
+ if (!index || index.sessionIds.length === 0)
237
+ continue;
238
+ chats[scopeId] = hydrateChatsForScope(scopeId);
239
+ currentChatId[scopeId] =
240
+ index.currentSessionId ?? chats[scopeId][0]?.session_id ?? null;
241
+ }
242
+ return { chats, currentChatId };
243
+ }
244
+
245
+ export { CHAT_SCOPE_IDS_REGISTRY_KEY, addScopeIdToRegistry, countSessionReferences, deleteSessionBlob, discoverScopeIdsFromLS, hasLegacyScopeData, hydrateChatsForScope, linkSessionToScope, loadChatsFromSessionStorage, loadScopeIndex, loadSession, migrateLegacyScope, persistScopeIndex, persistSession, removeSessionFromScope, renameSessionId, scopeIdsForUser };
@@ -0,0 +1,80 @@
1
+ const FENCE_OPEN = /```\s*json-dashboard\s*/i;
2
+ /** Unescape literal `\n` sequences when the agent double-encodes newlines. */
3
+ function normalizeJsonDashboardChatText(text) {
4
+ if (!text.includes('\\n'))
5
+ return text;
6
+ if (!FENCE_OPEN.test(text))
7
+ return text;
8
+ // Real newlines mean the fence is already formatted; unescaping would
9
+ // corrupt `\n` inside JSON string values (e.g. markdown code fences).
10
+ if (text.includes('\n'))
11
+ return text;
12
+ return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
13
+ }
14
+ function findCompleteJsonEnd(text, contentStart) {
15
+ let lastValidEnd = -1;
16
+ for (let i = contentStart + 1; i <= text.length; i++) {
17
+ const candidate = text.slice(contentStart, i);
18
+ if (!candidate.trim())
19
+ continue;
20
+ try {
21
+ JSON.parse(candidate);
22
+ lastValidEnd = i;
23
+ }
24
+ catch {
25
+ // keep extending until JSON is complete
26
+ }
27
+ }
28
+ return lastValidEnd;
29
+ }
30
+ function findJsonDashboardContentEnd(text, contentStart) {
31
+ const jsonEnd = findCompleteJsonEnd(text, contentStart);
32
+ if (jsonEnd < 0)
33
+ return -1;
34
+ const afterJson = text.slice(jsonEnd);
35
+ const fenceIdx = afterJson.indexOf('```');
36
+ if (fenceIdx >= 0) {
37
+ return jsonEnd + fenceIdx;
38
+ }
39
+ const tail = text.slice(contentStart).trim();
40
+ if (tail.length > 0) {
41
+ try {
42
+ JSON.parse(tail);
43
+ return text.length;
44
+ }
45
+ catch {
46
+ return jsonEnd;
47
+ }
48
+ }
49
+ return jsonEnd;
50
+ }
51
+ function jsonDashboardFenceSpan(text) {
52
+ const prepared = normalizeJsonDashboardChatText(text);
53
+ const startMatch = prepared.match(FENCE_OPEN);
54
+ if (!startMatch || startMatch.index === undefined)
55
+ return null;
56
+ const contentStart = startMatch.index + startMatch[0].length;
57
+ const closingFenceStart = findJsonDashboardContentEnd(prepared, contentStart);
58
+ if (closingFenceStart < 0) {
59
+ return { start: startMatch.index, end: prepared.length };
60
+ }
61
+ const end = closingFenceStart < prepared.length &&
62
+ prepared.startsWith('```', closingFenceStart)
63
+ ? closingFenceStart + 3
64
+ : closingFenceStart;
65
+ return { start: startMatch.index, end };
66
+ }
67
+ /** Removes fenced json-dashboard blocks (used before persisting chat messages). */
68
+ function stripJsonDashboardFences(text) {
69
+ let result = normalizeJsonDashboardChatText(text);
70
+ while (true) {
71
+ const span = jsonDashboardFenceSpan(result);
72
+ if (!span)
73
+ break;
74
+ result = result.slice(0, span.start) + result.slice(span.end);
75
+ }
76
+ result = result.replace(/\n{3,}/g, '\n\n');
77
+ return result.trim();
78
+ }
79
+
80
+ export { normalizeJsonDashboardChatText, stripJsonDashboardFences };
@@ -1,9 +1,4 @@
1
1
  import React from 'react';
2
- declare const injectHeaders: (content: string) => {
3
- elem: import("react/jsx-runtime").JSX.Element;
4
- index: number;
5
- length: number;
6
- };
7
2
  declare const injectBold: (content: string) => {
8
3
  elem: import("react/jsx-runtime").JSX.Element;
9
4
  index: number;
@@ -32,6 +27,7 @@ type Injector = (content: string) => {
32
27
  length: number;
33
28
  } | null;
34
29
  declare const injectAnchor: Injector;
30
+ declare const injectHeaders: Injector;
35
31
  declare const applyFormatting: (text: string) => React.ReactNode[];
36
32
  declare const applyFormattingInline: (text: string) => React.ReactNode[];
37
33
  export { injectHeaders, injectAnchor, injectBold, injectItalic, injectBullet, injectNewlines, convertMarkdownTableToHTML, applyFormatting, applyFormattingInline, };
@@ -35,6 +35,11 @@ export type UseChatPanelChromeModelInput = {
35
35
  copyHistoryOnNewChat?: boolean;
36
36
  /** Override or extend the default send payload (e.g. api vs display text split). */
37
37
  transformSendPayload?: (message: string, attachments: ChatAttachmentDropItem[] | undefined, defaultPayload: string | ChatSendMessagePayload) => string | ChatSendMessagePayload | Promise<string | ChatSendMessagePayload>;
38
+ /**
39
+ * When true, preset clicks always use the API send path (skip local `answer` /
40
+ * script demo). Use on /reports/new where first message must hit transformSendPayload.
41
+ */
42
+ submitPresetsViaApi?: boolean;
38
43
  };
39
44
  export type UseChatPanelChromeModelResult = {
40
45
  chromeProps: ChatChromeProps;
@@ -48,4 +53,4 @@ export type UseChatPanelChromeModelResult = {
48
53
  openNewChatAndSubmit: (prompt: string) => void;
49
54
  chatPanelContainer: HTMLElement | null;
50
55
  };
51
- export declare function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, renderSystemMessage, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, copyHistoryOnNewChat, transformSendPayload, }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult;
56
+ export declare function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, renderSystemMessage, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, copyHistoryOnNewChat, transformSendPayload, submitPresetsViaApi, }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult;
@@ -1,6 +1,7 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { type Chat, type ChatMeta, type ChatSendMessagePayload, type Message, MessageRole, type UserTextFileAttachment } from '#uilib/components/ui/Chat/Chat.types';
3
3
  import type { ChatResponse } from '#uilib/types/chat-api.types';
4
+ import type { LinkSessionToScopeOptions } from './chatSessionStorage';
4
5
  export type SendChatMessageFn = (message: string, targetChatId: string) => Promise<ChatResponse>;
5
6
  export type { ChatSendMessagePayload, UserTextFileAttachment, } from '#uilib/components/ui/Chat/Chat.types';
6
7
  export type AddChatMessageOptions = {
@@ -19,6 +20,7 @@ export type NewChatOptions = {
19
20
  /** When set, seeds the new session's messages (e.g. continue dialog in reports). */
20
21
  seedMessages?: readonly Message[];
21
22
  };
23
+ export type { LinkSessionToScopeOptions } from './chatSessionStorage';
22
24
  export type SendMessageResult = {
23
25
  response: string;
24
26
  sessionId: string;
@@ -26,6 +28,8 @@ export type SendMessageResult = {
26
28
  export interface ChatContextType {
27
29
  /** Returns the new session id, or undefined if no user / not created. */
28
30
  newChat: (scopeId: string, options?: NewChatOptions) => string | undefined;
31
+ /** Attach an existing session to a scope without cloning messages. */
32
+ linkSessionToScope: (scopeId: string, sessionId: string, options?: LinkSessionToScopeOptions) => void;
29
33
  setCurrentChatId: (currScopeId: string, sessionId: string) => void;
30
34
  addMessage: (scopeId: string, chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string | undefined;
31
35
  removeMessageById: (scopeId: string, chatId: string, messageId: string) => void;
@@ -72,6 +76,7 @@ export declare function useChatsForScopeId(scopeId: string): {
72
76
  currentChatId: string;
73
77
  isOutboundPending: boolean;
74
78
  setCurrentChatId: (targetId: string) => void;
79
+ linkSessionToScope: (sessionId: string, options?: LinkSessionToScopeOptions) => void;
75
80
  newChat: (options?: NewChatOptions) => string;
76
81
  addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
77
82
  removeMessageById: (chatId: string, messageId: string) => void;
@@ -88,6 +93,7 @@ export declare function useChatsForDataset(scopeId: string): {
88
93
  currentChatId: string;
89
94
  isOutboundPending: boolean;
90
95
  setCurrentChatId: (targetId: string) => void;
96
+ linkSessionToScope: (sessionId: string, options?: LinkSessionToScopeOptions) => void;
91
97
  newChat: (options?: NewChatOptions) => string;
92
98
  addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
93
99
  removeMessageById: (chatId: string, messageId: string) => void;
@@ -1,5 +1,8 @@
1
- import type { Chat, Message } from '#uilib/components/ui/Chat/Chat.types';
1
+ import { type Chat, type Message } from '#uilib/components/ui/Chat/Chat.types';
2
+ export declare function isEphemeralChatScope(scopeId: string): boolean;
2
3
  export declare function stripMessageForPersistence(message: Message): Message;
3
4
  export declare function stripChatsForPersistence(chats: Chat[]): Chat[];
5
+ export declare function persistSessionToLS(chat: Chat): void;
6
+ /** @deprecated Legacy scope blob — prefer session + scope index via chatSessionStorage. */
4
7
  export declare function persistChatsToLS(scopeId: string, chats: Chat[]): void;
5
8
  export declare function safeLsSet(key: string, value: unknown): void;
@@ -0,0 +1,32 @@
1
+ import type { Chat } from '#uilib/components/ui/Chat/Chat.types';
2
+ export type ChatScopeIndex = {
3
+ currentSessionId: string | null;
4
+ sessionIds: string[];
5
+ };
6
+ export declare const CHAT_SCOPE_IDS_REGISTRY_KEY = "chat-scope-ids";
7
+ export declare function loadSession(sessionId: string): Chat | null;
8
+ export declare function persistSession(chat: Chat): void;
9
+ export declare function deleteSessionBlob(sessionId: string): void;
10
+ export declare function loadScopeIndex(scopeId: string): ChatScopeIndex | null;
11
+ export declare function persistScopeIndex(scopeId: string, index: ChatScopeIndex): void;
12
+ export declare function addScopeIdToRegistry(scopeId: string): void;
13
+ export declare function discoverScopeIdsFromLS(): Set<string>;
14
+ export declare function scopeIdsForUser(userId: number, all: Set<string>): Set<string>;
15
+ export declare function hasLegacyScopeData(scopeId: string): boolean;
16
+ export declare function migrateLegacyScope(scopeId: string): ChatScopeIndex | null;
17
+ export type LinkSessionToScopeOptions = {
18
+ makeCurrent?: boolean;
19
+ };
20
+ export declare function linkSessionToScope(scopeId: string, sessionId: string, options?: LinkSessionToScopeOptions): ChatScopeIndex;
21
+ export declare function hydrateChatsForScope(scopeId: string): Chat[];
22
+ export declare function countSessionReferences(userId: number, sessionId: string): number;
23
+ export declare function removeSessionFromScope(scopeId: string, sessionId: string): ChatScopeIndex | null;
24
+ export declare function renameSessionId(userId: number, oldId: string, newId: string): void;
25
+ export declare function loadChatsFromSessionStorage(userId: number): {
26
+ chats: Record<string, Chat[]>;
27
+ currentChatId: Record<string, string | null>;
28
+ };
29
+ /** Alias used by ChatProvider mount hydration. */
30
+ export declare const loadAllScopesForUser: typeof loadChatsFromSessionStorage;
31
+ export declare function isSessionReferencedByScopes(sessionId: string, excludeScopeId: string | null, userId: number): boolean;
32
+ export declare function renameSessionIdInStorage(oldId: string, newId: string, userId: number): void;
@@ -0,0 +1,9 @@
1
+ export declare function hasJsonDashboardFenceInText(text: string): boolean;
2
+ /** Unescape literal `\n` sequences when the agent double-encodes newlines. */
3
+ export declare function normalizeJsonDashboardChatText(text: string): string;
4
+ export declare function jsonDashboardFenceBounds(text: string): {
5
+ contentStart: number;
6
+ contentEnd: number;
7
+ } | null;
8
+ /** Removes fenced json-dashboard blocks (used before persisting chat messages). */
9
+ export declare function stripJsonDashboardFences(text: string): string;
@@ -1,2 +1 @@
1
- /** Removes fenced json-dashboard blocks (used before persisting chat messages). */
2
- export declare function stripJsonDashboardFences(text: string): string;
1
+ export { hasJsonDashboardFenceInText, normalizeJsonDashboardChatText, stripJsonDashboardFences, } from './jsonDashboardFence';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.88",
3
+ "version": "1.3.90",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,25 @@
1
+ import { render } from '@testing-library/react';
2
+
3
+ import { applyFormatting } from './AgentMessageContent.helpers';
4
+
5
+ function renderFormatting(text: string) {
6
+ const parts = applyFormatting(text);
7
+ return render(<>{parts}</>);
8
+ }
9
+
10
+ describe('applyFormatting headers', () => {
11
+ it('renders bold inside markdown headers', () => {
12
+ const { container } = renderFormatting(
13
+ "### 2. **Monitor Estonia's Balance of Payments** ⭐",
14
+ );
15
+
16
+ const heading = container.querySelector('h3');
17
+ expect(heading).not.toBeNull();
18
+ expect(heading?.textContent).toBe(
19
+ "2. Monitor Estonia's Balance of Payments ⭐",
20
+ );
21
+ expect(heading?.querySelector('strong')?.textContent).toBe(
22
+ "Monitor Estonia's Balance of Payments",
23
+ );
24
+ });
25
+ });
@@ -9,32 +9,6 @@ import logger from '#uilib/lib/logger';
9
9
 
10
10
  import S from './ChatMessage.styl';
11
11
 
12
- const injectHeaders = (content: string) => {
13
- // Match #, ##, ###, or #### headers at start of line
14
- const regex = /(^|\n)(#{1,4})\s+(.+?)(?=\n|$)/m;
15
- const matches = content.match(regex);
16
-
17
- if (!matches) return null;
18
-
19
- const level = matches[2].length;
20
- const headerText = matches[3].replace(/^\*+|\*+$/g, '');
21
- const Tag =
22
- level === 1 ? 'h1' : level === 2 ? 'h2' : level === 3 ? 'h3' : 'h4';
23
-
24
- // Calculate the actual match position and length
25
- // matches[0] includes the leading \n if present, but we want to replace from the # position
26
- const hasLeadingNewline = matches[1] === '\n';
27
- const startIndex = matches.index! + (hasLeadingNewline ? 1 : 0);
28
- // Length is: # markers + space + header text (excluding leading newline)
29
- const length = matches[2].length + 1 + matches[3].length;
30
-
31
- return {
32
- elem: <Tag>{headerText}</Tag>,
33
- index: startIndex,
34
- length: length,
35
- };
36
- };
37
-
38
12
  /** Match sits inside list/table HTML (those blocks use dangerouslySetInnerHTML elsewhere). */
39
13
  const isInsideHtmlListOrTable = (content: string, matchStartIndex: number) => {
40
14
  const before = content.substring(0, matchStartIndex);
@@ -540,6 +514,41 @@ const injectAnchor: Injector = (content: string) => {
540
514
  };
541
515
  };
542
516
 
517
+ const headerTextInjectors: Injector[] = [
518
+ injectAnchor,
519
+ injectMarkdownLink,
520
+ injectHTMLTags,
521
+ injectBold,
522
+ injectItalic,
523
+ injectAutolinkUrl,
524
+ ];
525
+
526
+ const injectHeaders: Injector = (content: string) => {
527
+ // Match #, ##, ###, or #### headers at start of line
528
+ const regex = /(^|\n)(#{1,4})\s+(.+?)(?=\n|$)/m;
529
+ const matches = content.match(regex);
530
+
531
+ if (!matches) return null;
532
+
533
+ const level = matches[2].length;
534
+ const headerText = matches[3].replace(/^\*+|\*+$/g, '');
535
+ const Tag =
536
+ level === 1 ? 'h1' : level === 2 ? 'h2' : level === 3 ? 'h3' : 'h4';
537
+
538
+ // Calculate the actual match position and length
539
+ // matches[0] includes the leading \n if present, but we want to replace from the # position
540
+ const hasLeadingNewline = matches[1] === '\n';
541
+ const startIndex = matches.index! + (hasLeadingNewline ? 1 : 0);
542
+ // Length is: # markers + space + header text (excluding leading newline)
543
+ const length = matches[2].length + 1 + matches[3].length;
544
+
545
+ return {
546
+ elem: <Tag>{runFormattingPipeline(headerText, headerTextInjectors)}</Tag>,
547
+ index: startIndex,
548
+ length: length,
549
+ };
550
+ };
551
+
543
552
  const applyFormatting = (text: string): React.ReactNode[] =>
544
553
  runFormattingPipeline(text, [
545
554
  injectHeaders,
@@ -1,7 +1,9 @@
1
1
  import cn from 'classnames';
2
+ import { useMemo } from 'react';
2
3
 
3
4
  import { InteractiveContent } from '#uilib/components/ui/InteractiveContent';
4
5
  import { TextShimmer } from '#uilib/components/ui/TextShimmer';
6
+ import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
5
7
 
6
8
  import { type ChatMessageProps, MessageRole } from '../Chat.types';
7
9
  import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments';
@@ -32,6 +34,10 @@ export function ChatMessage({
32
34
  });
33
35
  const isAssistant = role === MessageRole.ASSISTANT;
34
36
  const isSystem = role === MessageRole.SYSTEM;
37
+ const assistantDisplayText = useMemo(
38
+ () => (isAssistant ? stripJsonDashboardFences(text) : text),
39
+ [isAssistant, text],
40
+ );
35
41
 
36
42
  return (
37
43
  <div className={cn(S.root, S[`role-${role}`], className)}>
@@ -47,7 +53,7 @@ export function ChatMessage({
47
53
  </div>
48
54
  ) : isAssistant ? (
49
55
  <AgentMessageContent
50
- text={text}
56
+ text={assistantDisplayText}
51
57
  textClassName={textClassName}
52
58
  onQuickReply={onQuickReply}
53
59
  suppressedQuickReplyKeys={suppressedQuickReplyKeys}