@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
@@ -1,7 +1,8 @@
1
- import type {
2
- Chat,
3
- Message,
4
- UserTextFileAttachment,
1
+ import {
2
+ type Chat,
3
+ type Message,
4
+ MessageRole,
5
+ type UserTextFileAttachment,
5
6
  } from '#uilib/components/ui/Chat/Chat.types';
6
7
  import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
7
8
  import { LS } from '@homecode/ui';
@@ -9,7 +10,7 @@ import { LS } from '@homecode/ui';
9
10
  /** Ephemeral scopes (e.g. /reports/new draft) — keep only the latest session in LS. */
10
11
  const EPHEMERAL_SCOPE_MARKERS = ['__reports_new_draft__'] as const;
11
12
 
12
- function isEphemeralChatScope(scopeId: string): boolean {
13
+ export function isEphemeralChatScope(scopeId: string): boolean {
13
14
  return EPHEMERAL_SCOPE_MARKERS.some(marker => scopeId.endsWith(marker));
14
15
  }
15
16
 
@@ -33,7 +34,10 @@ function stripAttachmentForPersistence(
33
34
  }
34
35
 
35
36
  export function stripMessageForPersistence(message: Message): Message {
36
- const text = stripJsonDashboardFences(message.text);
37
+ const text =
38
+ message.role === MessageRole.ASSISTANT
39
+ ? message.text
40
+ : stripJsonDashboardFences(message.text);
37
41
  const next: Message = { ...message, text };
38
42
  if (message.inProgress) {
39
43
  delete next.inProgress;
@@ -60,6 +64,18 @@ function chatsForScopePersistence(scopeId: string, chats: Chat[]): Chat[] {
60
64
  return stripChatsForPersistence(scopeChats);
61
65
  }
62
66
 
67
+ export function persistSessionToLS(chat: Chat): void {
68
+ const payload = stripChatsForPersistence([chat])[0];
69
+ if (!payload) return;
70
+ const sessionKey = `chat-session-${chat.session_id}`;
71
+ try {
72
+ LS.set(sessionKey, payload);
73
+ } catch (error) {
74
+ if (!isQuotaExceededError(error)) throw error;
75
+ }
76
+ }
77
+
78
+ /** @deprecated Legacy scope blob — prefer session + scope index via chatSessionStorage. */
63
79
  export function persistChatsToLS(scopeId: string, chats: Chat[]): void {
64
80
  const chatsKey = `chats-${scopeId}`;
65
81
  const payload = chatsForScopePersistence(scopeId, chats);
@@ -0,0 +1,125 @@
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
+ countSessionReferences,
6
+ deleteSessionBlob,
7
+ hydrateChatsForScope,
8
+ linkSessionToScope,
9
+ loadScopeIndex,
10
+ loadSession,
11
+ migrateLegacyScope,
12
+ persistSession,
13
+ removeSessionFromScope,
14
+ } from './chatSessionStorage';
15
+
16
+ const USER_ID = 42;
17
+ const DATASET_SCOPE = `${USER_ID}-dataset-1`;
18
+ const REPORT_SCOPE = `${USER_ID}-chats-report-r1`;
19
+ const SESSION_A = 'session-a';
20
+ const SESSION_B = 'session-b';
21
+
22
+ function makeChat(sessionId: string, messageCount: number): Chat {
23
+ const messages = Array.from({ length: messageCount }, (_, index) => ({
24
+ id: `${sessionId}-msg-${index}`,
25
+ role: MessageRole.USER,
26
+ text: `message-${index}`,
27
+ timestamp: index,
28
+ }));
29
+ return { session_id: sessionId, name: '', messages };
30
+ }
31
+
32
+ describe('chatSessionStorage migration', () => {
33
+ beforeEach(() => {
34
+ window.localStorage.clear();
35
+ });
36
+
37
+ it('migrates legacy chats-* into session blobs and scope index', () => {
38
+ const legacyChats = [makeChat(SESSION_A, 2), makeChat(SESSION_B, 1)];
39
+ window.localStorage.setItem(
40
+ `chats-${DATASET_SCOPE}`,
41
+ JSON.stringify(legacyChats),
42
+ );
43
+ window.localStorage.setItem(
44
+ `chat-current-id-${DATASET_SCOPE}`,
45
+ JSON.stringify(SESSION_B),
46
+ );
47
+
48
+ migrateLegacyScope(DATASET_SCOPE);
49
+
50
+ expect(window.localStorage.getItem(`chats-${DATASET_SCOPE}`)).toBeNull();
51
+ expect(
52
+ window.localStorage.getItem(`chat-current-id-${DATASET_SCOPE}`),
53
+ ).toBeNull();
54
+
55
+ const index = loadScopeIndex(DATASET_SCOPE);
56
+ expect(index?.sessionIds).toEqual([SESSION_A, SESSION_B]);
57
+ expect(index?.currentSessionId).toBe(SESSION_B);
58
+ expect(loadSession(SESSION_A)?.messages).toHaveLength(2);
59
+ expect(loadSession(SESSION_B)?.messages).toHaveLength(1);
60
+ });
61
+
62
+ it('keeps the richer session blob when duplicate ids exist during migration', () => {
63
+ const sparse = makeChat(SESSION_A, 1);
64
+ const rich = makeChat(SESSION_A, 4);
65
+ window.localStorage.setItem(
66
+ `chats-${DATASET_SCOPE}`,
67
+ JSON.stringify([sparse, rich]),
68
+ );
69
+
70
+ migrateLegacyScope(DATASET_SCOPE);
71
+
72
+ expect(loadSession(SESSION_A)?.messages).toHaveLength(4);
73
+ });
74
+ });
75
+
76
+ describe('chatSessionStorage shared sessions', () => {
77
+ beforeEach(() => {
78
+ window.localStorage.clear();
79
+ });
80
+
81
+ it('shares one session blob across two scopes', () => {
82
+ persistSession(makeChat(SESSION_A, 1));
83
+ linkSessionToScope(DATASET_SCOPE, SESSION_A, { makeCurrent: true });
84
+ linkSessionToScope(REPORT_SCOPE, SESSION_A, { makeCurrent: true });
85
+
86
+ const datasetChats = hydrateChatsForScope(DATASET_SCOPE);
87
+ expect(datasetChats[0]?.messages).toHaveLength(1);
88
+
89
+ const session = loadSession(SESSION_A);
90
+ if (!session) throw new Error('expected session');
91
+ persistSession({
92
+ ...session,
93
+ messages: [
94
+ ...session.messages,
95
+ {
96
+ id: 'follow-up',
97
+ role: MessageRole.ASSISTANT,
98
+ text: 'merged',
99
+ timestamp: 99,
100
+ },
101
+ ],
102
+ });
103
+
104
+ const reportChats = hydrateChatsForScope(REPORT_SCOPE);
105
+ expect(reportChats[0]?.messages).toHaveLength(2);
106
+ expect(reportChats[0]?.messages[1]?.text).toBe('merged');
107
+ });
108
+
109
+ it('keeps session blob until the last scope reference is removed', () => {
110
+ persistSession(makeChat(SESSION_A, 1));
111
+ linkSessionToScope(DATASET_SCOPE, SESSION_A);
112
+ linkSessionToScope(REPORT_SCOPE, SESSION_A);
113
+
114
+ expect(countSessionReferences(USER_ID, SESSION_A)).toBe(2);
115
+
116
+ removeSessionFromScope(DATASET_SCOPE, SESSION_A);
117
+ expect(loadSession(SESSION_A)).not.toBeNull();
118
+ expect(countSessionReferences(USER_ID, SESSION_A)).toBe(1);
119
+
120
+ removeSessionFromScope(REPORT_SCOPE, SESSION_A);
121
+ expect(countSessionReferences(USER_ID, SESSION_A)).toBe(0);
122
+ deleteSessionBlob(SESSION_A);
123
+ expect(loadSession(SESSION_A)).toBeNull();
124
+ });
125
+ });
@@ -0,0 +1,321 @@
1
+ import type { Chat } from '#uilib/components/ui/Chat/Chat.types';
2
+ import { LS } from '@homecode/ui';
3
+
4
+ import {
5
+ isEphemeralChatScope,
6
+ safeLsSet,
7
+ stripChatsForPersistence,
8
+ } from './chatPersistence';
9
+
10
+ export type ChatScopeIndex = {
11
+ currentSessionId: string | null;
12
+ sessionIds: string[];
13
+ };
14
+
15
+ export const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
16
+
17
+ const SESSION_PREFIX = 'chat-session-';
18
+ const SCOPE_PREFIX = 'chat-scope-';
19
+ const CHATS_PREFIX = 'chats-';
20
+ const CHAT_CURRENT_ID_PREFIX = 'chat-current-id-';
21
+
22
+ function getSessionKey(sessionId: string): string {
23
+ return `${SESSION_PREFIX}${sessionId}`;
24
+ }
25
+
26
+ function getScopeKey(scopeId: string): string {
27
+ return `${SCOPE_PREFIX}${scopeId}`;
28
+ }
29
+
30
+ function getLegacyChatsKey(scopeId: string): string {
31
+ return `${CHATS_PREFIX}${scopeId}`;
32
+ }
33
+
34
+ function getLegacyCurrentIdKey(scopeId: string): string {
35
+ return `${CHAT_CURRENT_ID_PREFIX}${scopeId}`;
36
+ }
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === 'object' && value !== null;
40
+ }
41
+
42
+ function isChat(value: unknown): value is Chat {
43
+ if (!isRecord(value)) return false;
44
+ return (
45
+ typeof value.session_id === 'string' &&
46
+ typeof value.name === 'string' &&
47
+ Array.isArray(value.messages)
48
+ );
49
+ }
50
+
51
+ function isChatScopeIndex(value: unknown): value is ChatScopeIndex {
52
+ if (!isRecord(value)) return false;
53
+ const { currentSessionId, sessionIds } = value;
54
+ if (currentSessionId !== null && typeof currentSessionId !== 'string') {
55
+ return false;
56
+ }
57
+ if (!Array.isArray(sessionIds)) return false;
58
+ return sessionIds.every(id => typeof id === 'string');
59
+ }
60
+
61
+ function trimScopeIndexForPersistence(
62
+ scopeId: string,
63
+ index: ChatScopeIndex,
64
+ ): ChatScopeIndex {
65
+ if (!isEphemeralChatScope(scopeId)) return index;
66
+ const current = index.currentSessionId ?? index.sessionIds[0] ?? null;
67
+ if (!current) return { currentSessionId: null, sessionIds: [] };
68
+ return { currentSessionId: current, sessionIds: [current] };
69
+ }
70
+
71
+ function mergeSessionChat(existing: Chat | null, incoming: Chat): Chat {
72
+ if (!existing) return incoming;
73
+ return incoming.messages.length >= existing.messages.length
74
+ ? incoming
75
+ : existing;
76
+ }
77
+
78
+ export function loadSession(sessionId: string): Chat | null {
79
+ const raw = LS.get(getSessionKey(sessionId));
80
+ return isChat(raw) ? raw : null;
81
+ }
82
+
83
+ export function persistSession(chat: Chat): void {
84
+ const [stripped] = stripChatsForPersistence([chat]);
85
+ safeLsSet(getSessionKey(chat.session_id), stripped);
86
+ }
87
+
88
+ export function deleteSessionBlob(sessionId: string): void {
89
+ LS.remove(getSessionKey(sessionId));
90
+ }
91
+
92
+ export function loadScopeIndex(scopeId: string): ChatScopeIndex | null {
93
+ const raw = LS.get(getScopeKey(scopeId));
94
+ return isChatScopeIndex(raw) ? raw : null;
95
+ }
96
+
97
+ export function persistScopeIndex(
98
+ scopeId: string,
99
+ index: ChatScopeIndex,
100
+ ): void {
101
+ safeLsSet(getScopeKey(scopeId), trimScopeIndexForPersistence(scopeId, index));
102
+ }
103
+
104
+ export function addScopeIdToRegistry(scopeId: string): void {
105
+ const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
106
+ const registry = Array.isArray(raw) ? [...raw] : [];
107
+ if (!registry.includes(scopeId)) {
108
+ safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
109
+ }
110
+ }
111
+
112
+ export function discoverScopeIdsFromLS(): Set<string> {
113
+ const scopeIds = new Set<string>();
114
+ const registryRaw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
115
+ const registry = Array.isArray(registryRaw) ? registryRaw : [];
116
+ registry.forEach((id: unknown) => {
117
+ if (typeof id === 'string' && id) scopeIds.add(id);
118
+ });
119
+ try {
120
+ for (let i = 0; i < window.localStorage.length; i++) {
121
+ const key = window.localStorage.key(i);
122
+ if (!key) continue;
123
+ if (key.startsWith(SCOPE_PREFIX)) {
124
+ const scopeId = key.slice(SCOPE_PREFIX.length);
125
+ if (scopeId) scopeIds.add(scopeId);
126
+ } else if (key.startsWith(CHATS_PREFIX)) {
127
+ const scopeId = key.slice(CHATS_PREFIX.length);
128
+ if (scopeId) scopeIds.add(scopeId);
129
+ }
130
+ }
131
+ } catch {
132
+ // ignore
133
+ }
134
+ return scopeIds;
135
+ }
136
+
137
+ export function scopeIdsForUser(userId: number, all: Set<string>): Set<string> {
138
+ const prefix = `${userId}-`;
139
+ const out = new Set<string>();
140
+ for (const id of all) {
141
+ if (id.startsWith(prefix)) out.add(id);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ export function hasLegacyScopeData(scopeId: string): boolean {
147
+ const legacyRaw = LS.get(getLegacyChatsKey(scopeId));
148
+ return Array.isArray(legacyRaw) && legacyRaw.length > 0;
149
+ }
150
+
151
+ export function migrateLegacyScope(scopeId: string): ChatScopeIndex | null {
152
+ const legacyRaw = LS.get(getLegacyChatsKey(scopeId));
153
+ const legacyChats = Array.isArray(legacyRaw) ? legacyRaw : [];
154
+ const chats: Chat[] = [];
155
+ legacyChats.forEach(item => {
156
+ if (isChat(item)) chats.push(item);
157
+ });
158
+ if (chats.length === 0) return loadScopeIndex(scopeId);
159
+
160
+ const savedCurrentRaw = LS.get(getLegacyCurrentIdKey(scopeId));
161
+ const savedCurrentId =
162
+ typeof savedCurrentRaw === 'string' && savedCurrentRaw !== ''
163
+ ? savedCurrentRaw
164
+ : undefined;
165
+ const currentSessionId = savedCurrentId ?? chats[0]?.session_id ?? null;
166
+ const sessionIds = chats.map(chat => chat.session_id);
167
+
168
+ chats.forEach(chat => {
169
+ const existing = loadSession(chat.session_id);
170
+ persistSession(mergeSessionChat(existing, chat));
171
+ });
172
+
173
+ const index: ChatScopeIndex = { currentSessionId, sessionIds };
174
+ persistScopeIndex(scopeId, index);
175
+ addScopeIdToRegistry(scopeId);
176
+ LS.remove(getLegacyChatsKey(scopeId));
177
+ LS.remove(getLegacyCurrentIdKey(scopeId));
178
+ return index;
179
+ }
180
+
181
+ export type LinkSessionToScopeOptions = {
182
+ makeCurrent?: boolean;
183
+ };
184
+
185
+ export function linkSessionToScope(
186
+ scopeId: string,
187
+ sessionId: string,
188
+ options?: LinkSessionToScopeOptions,
189
+ ): ChatScopeIndex {
190
+ addScopeIdToRegistry(scopeId);
191
+ const existing = loadScopeIndex(scopeId) ?? {
192
+ currentSessionId: null,
193
+ sessionIds: [],
194
+ };
195
+ const sessionIds = existing.sessionIds.includes(sessionId)
196
+ ? existing.sessionIds
197
+ : [sessionId, ...existing.sessionIds];
198
+ const makeCurrent = options?.makeCurrent !== false;
199
+ const index: ChatScopeIndex = {
200
+ sessionIds,
201
+ currentSessionId: makeCurrent ? sessionId : existing.currentSessionId,
202
+ };
203
+ persistScopeIndex(scopeId, index);
204
+ return index;
205
+ }
206
+
207
+ export function hydrateChatsForScope(scopeId: string): Chat[] {
208
+ const index = loadScopeIndex(scopeId);
209
+ if (!index) return [];
210
+ return index.sessionIds
211
+ .map(sessionId => loadSession(sessionId))
212
+ .filter((chat): chat is Chat => chat != null);
213
+ }
214
+
215
+ export function countSessionReferences(
216
+ userId: number,
217
+ sessionId: string,
218
+ ): number {
219
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
220
+ let count = 0;
221
+ for (const scopeId of scopeIds) {
222
+ const index = loadScopeIndex(scopeId);
223
+ if (index?.sessionIds.includes(sessionId)) count += 1;
224
+ }
225
+ return count;
226
+ }
227
+
228
+ export function removeSessionFromScope(
229
+ scopeId: string,
230
+ sessionId: string,
231
+ ): ChatScopeIndex | null {
232
+ const existing = loadScopeIndex(scopeId);
233
+ if (!existing) return null;
234
+ const sessionIds = existing.sessionIds.filter(id => id !== sessionId);
235
+ if (sessionIds.length === existing.sessionIds.length) return existing;
236
+
237
+ let nextCurrent = existing.currentSessionId;
238
+ if (nextCurrent === sessionId) {
239
+ const deletedIndex = existing.sessionIds.indexOf(sessionId);
240
+ nextCurrent =
241
+ deletedIndex > 0
242
+ ? existing.sessionIds[deletedIndex - 1]
243
+ : (sessionIds[0] ?? null);
244
+ }
245
+
246
+ const index: ChatScopeIndex = {
247
+ currentSessionId: nextCurrent,
248
+ sessionIds,
249
+ };
250
+ persistScopeIndex(scopeId, index);
251
+ return index;
252
+ }
253
+
254
+ export function renameSessionId(
255
+ userId: number,
256
+ oldId: string,
257
+ newId: string,
258
+ ): void {
259
+ if (oldId === newId) return;
260
+ const chat = loadSession(oldId);
261
+ if (!chat) return;
262
+ persistSession({ ...chat, session_id: newId });
263
+ LS.remove(getSessionKey(oldId));
264
+
265
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
266
+ for (const scopeId of scopeIds) {
267
+ const index = loadScopeIndex(scopeId);
268
+ if (!index?.sessionIds.includes(oldId)) continue;
269
+ const sessionIds = index.sessionIds.map(id => (id === oldId ? newId : id));
270
+ const currentSessionId =
271
+ index.currentSessionId === oldId ? newId : index.currentSessionId;
272
+ persistScopeIndex(scopeId, { currentSessionId, sessionIds });
273
+ }
274
+ }
275
+
276
+ export function loadChatsFromSessionStorage(userId: number): {
277
+ chats: Record<string, Chat[]>;
278
+ currentChatId: Record<string, string | null>;
279
+ } {
280
+ const chats: Record<string, Chat[]> = {};
281
+ const currentChatId: Record<string, string | null> = {};
282
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
283
+
284
+ for (const scopeId of scopeIds) {
285
+ if (hasLegacyScopeData(scopeId)) {
286
+ migrateLegacyScope(scopeId);
287
+ }
288
+ const index = loadScopeIndex(scopeId);
289
+ if (!index || index.sessionIds.length === 0) continue;
290
+ chats[scopeId] = hydrateChatsForScope(scopeId);
291
+ currentChatId[scopeId] =
292
+ index.currentSessionId ?? chats[scopeId][0]?.session_id ?? null;
293
+ }
294
+
295
+ return { chats, currentChatId };
296
+ }
297
+
298
+ /** Alias used by ChatProvider mount hydration. */
299
+ export const loadAllScopesForUser = loadChatsFromSessionStorage;
300
+
301
+ export function isSessionReferencedByScopes(
302
+ sessionId: string,
303
+ excludeScopeId: string | null,
304
+ userId: number,
305
+ ): boolean {
306
+ const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
307
+ for (const scopeId of scopeIds) {
308
+ if (excludeScopeId && scopeId === excludeScopeId) continue;
309
+ const index = loadScopeIndex(scopeId);
310
+ if (index?.sessionIds.includes(sessionId)) return true;
311
+ }
312
+ return false;
313
+ }
314
+
315
+ export function renameSessionIdInStorage(
316
+ oldId: string,
317
+ newId: string,
318
+ userId: number,
319
+ ): void {
320
+ renameSessionId(userId, oldId, newId);
321
+ }
@@ -0,0 +1,98 @@
1
+ const FENCE_OPEN = /```\s*json-dashboard\s*/i;
2
+
3
+ export function hasJsonDashboardFenceInText(text: string): boolean {
4
+ return FENCE_OPEN.test(text);
5
+ }
6
+
7
+ /** Unescape literal `\n` sequences when the agent double-encodes newlines. */
8
+ export function normalizeJsonDashboardChatText(text: string): string {
9
+ if (!text.includes('\\n')) return text;
10
+ if (!FENCE_OPEN.test(text)) return text;
11
+ // Real newlines mean the fence is already formatted; unescaping would
12
+ // corrupt `\n` inside JSON string values (e.g. markdown code fences).
13
+ if (text.includes('\n')) return text;
14
+ return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
15
+ }
16
+
17
+ function findCompleteJsonEnd(text: string, contentStart: number): number {
18
+ let lastValidEnd = -1;
19
+ for (let i = contentStart + 1; i <= text.length; i++) {
20
+ const candidate = text.slice(contentStart, i);
21
+ if (!candidate.trim()) continue;
22
+ try {
23
+ JSON.parse(candidate);
24
+ lastValidEnd = i;
25
+ } catch {
26
+ // keep extending until JSON is complete
27
+ }
28
+ }
29
+ return lastValidEnd;
30
+ }
31
+
32
+ function findJsonDashboardContentEnd(
33
+ text: string,
34
+ contentStart: number,
35
+ ): number {
36
+ const jsonEnd = findCompleteJsonEnd(text, contentStart);
37
+ if (jsonEnd < 0) return -1;
38
+
39
+ const afterJson = text.slice(jsonEnd);
40
+ const fenceIdx = afterJson.indexOf('```');
41
+ if (fenceIdx >= 0) {
42
+ return jsonEnd + fenceIdx;
43
+ }
44
+
45
+ const tail = text.slice(contentStart).trim();
46
+ if (tail.length > 0) {
47
+ try {
48
+ JSON.parse(tail);
49
+ return text.length;
50
+ } catch {
51
+ return jsonEnd;
52
+ }
53
+ }
54
+ return jsonEnd;
55
+ }
56
+
57
+ export function jsonDashboardFenceBounds(
58
+ text: string,
59
+ ): { contentStart: number; contentEnd: number } | null {
60
+ const prepared = normalizeJsonDashboardChatText(text);
61
+ const startMatch = prepared.match(FENCE_OPEN);
62
+ if (!startMatch || startMatch.index === undefined) return null;
63
+ const contentStart = startMatch.index + startMatch[0].length;
64
+ const contentEnd = findJsonDashboardContentEnd(prepared, contentStart);
65
+ if (contentEnd < 0 || contentEnd <= contentStart) return null;
66
+ return { contentStart, contentEnd };
67
+ }
68
+
69
+ function jsonDashboardFenceSpan(
70
+ text: string,
71
+ ): { start: number; end: number } | null {
72
+ const prepared = normalizeJsonDashboardChatText(text);
73
+ const startMatch = prepared.match(FENCE_OPEN);
74
+ if (!startMatch || startMatch.index === undefined) return null;
75
+ const contentStart = startMatch.index + startMatch[0].length;
76
+ const closingFenceStart = findJsonDashboardContentEnd(prepared, contentStart);
77
+ if (closingFenceStart < 0) {
78
+ return { start: startMatch.index, end: prepared.length };
79
+ }
80
+ const end =
81
+ closingFenceStart < prepared.length &&
82
+ prepared.startsWith('```', closingFenceStart)
83
+ ? closingFenceStart + 3
84
+ : closingFenceStart;
85
+ return { start: startMatch.index, end };
86
+ }
87
+
88
+ /** Removes fenced json-dashboard blocks (used before persisting chat messages). */
89
+ export function stripJsonDashboardFences(text: string): string {
90
+ let result = normalizeJsonDashboardChatText(text);
91
+ while (true) {
92
+ const span = jsonDashboardFenceSpan(result);
93
+ if (!span) break;
94
+ result = result.slice(0, span.start) + result.slice(span.end);
95
+ }
96
+ result = result.replace(/\n{3,}/g, '\n\n');
97
+ return result.trim();
98
+ }
@@ -0,0 +1,84 @@
1
+ import { stripJsonDashboardFences } from './stripJsonDashboardFences';
2
+
3
+ describe('stripJsonDashboardFences', () => {
4
+ it('removes a closed json-dashboard fence', () => {
5
+ const text = 'Summary\n```json-dashboard\n{"tiles":[]}\n```';
6
+ expect(stripJsonDashboardFences(text)).toBe('Summary');
7
+ });
8
+
9
+ it('removes multiple closed fences', () => {
10
+ const text = [
11
+ 'Intro',
12
+ '```json-dashboard',
13
+ '{"a":1}',
14
+ '```',
15
+ 'Middle',
16
+ '```json-dashboard',
17
+ '{"b":2}',
18
+ '```',
19
+ 'Outro',
20
+ ].join('\n');
21
+ expect(stripJsonDashboardFences(text)).toBe('Intro\n\nMiddle\n\nOutro');
22
+ });
23
+
24
+ it('strips from an unclosed fence to end of text', () => {
25
+ const text = 'Summary\n```json-dashboard\n{"tiles":[]}';
26
+ expect(stripJsonDashboardFences(text)).toBe('Summary');
27
+ });
28
+
29
+ it('strips fence with space after backticks', () => {
30
+ const text = 'Summary\n``` json-dashboard\n{"tiles":[]}\n```';
31
+ expect(stripJsonDashboardFences(text)).toBe('Summary');
32
+ });
33
+
34
+ it('preserves trailing markdown fences after dashboard block', () => {
35
+ const text = [
36
+ 'Here is the layout:',
37
+ '```json-dashboard',
38
+ '{"tiles":[]}',
39
+ '```',
40
+ '',
41
+ '```sql',
42
+ 'SELECT 1',
43
+ '```',
44
+ ].join('\n');
45
+ expect(stripJsonDashboardFences(text)).toBe(
46
+ ['Here is the layout:', '', '```sql', 'SELECT 1', '```'].join('\n'),
47
+ );
48
+ });
49
+
50
+ it('strips dashboard block when json contains markdown code fences', () => {
51
+ const spec = {
52
+ schemaVersion: 1,
53
+ tiles: [
54
+ {
55
+ id: 't1',
56
+ widget: 'markdown',
57
+ bindings: {
58
+ content: 'Example:\n```sql\nSELECT 1\n```\nMore text',
59
+ },
60
+ },
61
+ ],
62
+ };
63
+ const text = [
64
+ '```json-dashboard',
65
+ JSON.stringify(spec),
66
+ '```',
67
+ '',
68
+ 'Kenya Protest',
69
+ '',
70
+ 'Geopolitical Risk',
71
+ ].join('\n');
72
+ expect(stripJsonDashboardFences(text)).toBe(
73
+ ['Kenya Protest', '', 'Geopolitical Risk'].join('\n'),
74
+ );
75
+ });
76
+
77
+ it('normalizes literal escaped newlines in follow-up responses', () => {
78
+ const text =
79
+ '```json-dashboard\\n{"tiles":[]}\\n```\\n\\nKenya Protest\\n\\nFunny summary';
80
+ expect(stripJsonDashboardFences(text)).toBe(
81
+ ['Kenya Protest', '', 'Funny summary'].join('\n'),
82
+ );
83
+ });
84
+ });
@@ -1,6 +1,5 @@
1
- const FENCE_STRIP = /```json-dashboard\s*[\s\S]*?```/gi;
2
-
3
- /** Removes fenced json-dashboard blocks (used before persisting chat messages). */
4
- export function stripJsonDashboardFences(text: string): string {
5
- return text.replace(FENCE_STRIP, '').trim();
6
- }
1
+ export {
2
+ hasJsonDashboardFenceInText,
3
+ normalizeJsonDashboardChatText,
4
+ stripJsonDashboardFences,
5
+ } from './jsonDashboardFence';
@@ -1,7 +0,0 @@
1
- const FENCE_STRIP = /```json-dashboard\s*[\s\S]*?```/gi;
2
- /** Removes fenced json-dashboard blocks (used before persisting chat messages). */
3
- function stripJsonDashboardFences(text) {
4
- return text.replace(FENCE_STRIP, '').trim();
5
- }
6
-
7
- export { stripJsonDashboardFences };