@svadmin/ui 0.40.6 → 0.41.0

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.
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
- /* eslint-disable no-undef, svelte/no-at-html-tags */
3
- import { getChatProvider, getChatContext, setChatContext, getAgentProvider, registerApproval, resolveApproval } from '@svadmin/core';
4
- import type { ChatMessage, ChatAction } from '@svadmin/core';
2
+ /* eslint-disable svelte/no-at-html-tags */
3
+ import { captureAdminContext } from '@svadmin/core';
4
+ import type { AgentProvider, ChatMessage, ChatAction, ChatContext, ChatProvider } from '@svadmin/core';
5
5
  import { useTranslation } from '@svadmin/core/i18n';
6
6
 
7
7
  import { useParsed } from '@svadmin/core';
8
+ import { onDestroy, untrack } from 'svelte';
9
+ import { SvelteMap } from 'svelte/reactivity';
8
10
  import { fly, fade, scale } from 'svelte/transition';
9
11
  import { Button } from './ui/button/index.js';
10
12
  import TooltipButton from './TooltipButton.svelte';
@@ -15,7 +17,11 @@
15
17
  interface Props {
16
18
  /** Render the launcher inside a layout toolbar instead of as a floating action button. */
17
19
  docked?: boolean;
18
- /** localStorage key for chat history persistence. Set to '' to disable. */
20
+ /**
21
+ * Panel suffix for chat history persistence. Both default and explicit
22
+ * suffixes are scoped by backend API + typed tenant identity.
23
+ * Set to '' to disable persistence.
24
+ */
19
25
  persistKey?: string;
20
26
  /** Custom persistence callback — called when messages change */
21
27
  onPersist?: (messages: ChatMessage[]) => void;
@@ -23,15 +29,24 @@
23
29
  onRestore?: () => ChatMessage[];
24
30
  /** Callback when user clicks an action button in an assistant message */
25
31
  onAction?: (action: ChatAction, message: ChatMessage) => void;
32
+ /** Stable target for tree-scoped ask-ai events. */
33
+ scope?: string;
34
+ /** Owning Layout scope used to arbitrate global keyboard shortcuts. */
35
+ ownerScope?: string;
26
36
  }
27
37
 
28
38
  const {
29
39
  docked = false,
30
- persistKey = 'svadmin-chat',
40
+ persistKey,
31
41
  onPersist,
32
42
  onRestore,
33
43
  onAction,
44
+ scope,
45
+ ownerScope,
34
46
  }: Props = $props();
47
+ const generatedId = $props.id();
48
+ const generatedScope = `svadmin-chat-${generatedId}`;
49
+ const componentScope = $derived(scope ?? generatedScope);
35
50
 
36
51
  let open = $state(false);
37
52
  let minimized = $state(false);
@@ -40,101 +55,363 @@
40
55
  let isStreaming = $state(false);
41
56
  let messagesContainer: HTMLDivElement | undefined = $state();
42
57
  let abortController: AbortController | null = null;
58
+ let runEpoch = 0;
59
+ let messageRevision = 0;
43
60
  let messageIdCounter = $state(0);
44
- let initialized = $state(false);
45
61
 
46
- const provider = $derived(getChatProvider());
47
- const agent = $derived(getAgentProvider());
62
+ const adminContext = captureAdminContext();
63
+ const provider = $derived(adminContext.chatProvider);
64
+ const agent = $derived(adminContext.agentProvider);
48
65
  const parsed = useParsed();
66
+ const chatContext = $derived.by((): ChatContext => ({
67
+ currentResource: parsed.resource,
68
+ selectedRecordId: parsed.id,
69
+ currentView: parsed.action as ChatContext['currentView'],
70
+ pathname: `/${parsed.resource ?? ''}${parsed.action ? '/' + parsed.action : ''}${parsed.id ? '/' + parsed.id : ''}`,
71
+ }));
72
+
73
+ const approvalCallbacks = new SvelteMap<string, (approved: boolean) => void>();
74
+
75
+ function stripApprovalActions(persist: boolean) {
76
+ let changed = false;
77
+ const nextMessages = messages.map((message) => {
78
+ if (!message.actions?.some((action) => (
79
+ action.payload != null
80
+ && Object.prototype.hasOwnProperty.call(action.payload, 'approvalId')
81
+ ))) return message;
82
+ changed = true;
83
+ const remainingActions = message.actions.filter((action) => (
84
+ action.payload == null
85
+ || !Object.prototype.hasOwnProperty.call(action.payload, 'approvalId')
86
+ ));
87
+ return {
88
+ ...message,
89
+ actions: remainingActions.length > 0 ? remainingActions : undefined,
90
+ };
91
+ });
92
+ if (!changed) return;
93
+ replaceMessages(nextMessages, persist);
94
+ if (persist) {
95
+ // User-triggered revocation must survive a remount immediately.
96
+ flushPendingPersist();
97
+ }
98
+ }
99
+
100
+ function invalidateActiveRun({
101
+ persistApprovalRevocation = true,
102
+ }: { persistApprovalRevocation?: boolean } = {}) {
103
+ runEpoch++;
104
+ const controller = abortController;
105
+ abortController = null;
106
+ isStreaming = false;
107
+ controller?.abort();
108
+ approvalCallbacks.clear();
109
+ stripApprovalActions(persistApprovalRevocation);
110
+ }
49
111
 
50
- /** Track pending approval requests for UI rendering */
51
- let pendingApprovalIds = $state<string[]>([]);
112
+ function isCurrentRun(epoch: number, controller: AbortController) {
113
+ return runEpoch === epoch
114
+ && abortController === controller
115
+ && !controller.signal.aborted;
116
+ }
52
117
 
53
- // ─── Auto-update ChatContext from current route ─────────────
54
- $effect(() => {
55
- setChatContext({
56
- currentResource: parsed.resource,
57
- selectedRecordId: parsed.id,
58
- currentView: parsed.action as 'list' | 'edit' | 'create' | 'show' | undefined,
59
- pathname: `/${parsed.resource ?? ''}${parsed.action ? '/' + parsed.action : ''}${parsed.id ? '/' + parsed.id : ''}`,
60
- });
118
+ let previousRunScope: {
119
+ tenantIdentity: string | number | undefined;
120
+ chatProvider: ChatProvider | null;
121
+ agentProvider: AgentProvider | null;
122
+ } | undefined;
123
+
124
+ const historyScopeToken = $derived.by(() => {
125
+ let apiUrl = 'unconfigured';
126
+ try {
127
+ apiUrl = adminContext.getDataProvider().getApiUrl();
128
+ } catch {
129
+ // Standalone legacy consumers may configure chat without a DataProvider.
130
+ }
131
+ const tenantIdentity = adminContext.tenantCacheKey?.__svadminTenant;
132
+ const typedTenant = tenantIdentity === undefined
133
+ ? null
134
+ : [typeof tenantIdentity, String(tenantIdentity)];
135
+ const baseKey = `svadmin-chat:${encodeURIComponent(JSON.stringify([apiUrl, typedTenant]))}`;
136
+ return persistKey === undefined
137
+ ? baseKey
138
+ : `${baseKey}:${encodeURIComponent(JSON.stringify(persistKey))}`;
61
139
  });
140
+ const resolvedPersistKey = $derived(persistKey === '' ? '' : historyScopeToken);
141
+
142
+ interface AskAIEventDetail {
143
+ query: string;
144
+ /** ChatDialog's data-svadmin-chat-scope value. */
145
+ scope?: string;
146
+ /** Compatibility alias for scope. */
147
+ target?: string;
148
+ }
62
149
 
63
150
  // ─── Listen for cross-component triggers ─────────────────────
64
151
  $effect(() => {
65
152
  if (typeof window === 'undefined') return;
66
- const handler = (e: CustomEvent<string>) => {
153
+ const handler = (event: CustomEvent<string | AskAIEventDetail>) => {
154
+ const detail = event.detail;
155
+ if (typeof detail === 'string') return;
156
+ const query = detail?.query;
157
+ const target = detail?.scope ?? detail?.target;
158
+ if (!query?.trim() || target !== componentScope) return;
159
+
67
160
  open = true;
68
161
  minimized = false;
69
- inputValue = e.detail;
70
- sendMessage();
162
+ inputValue = query;
163
+ void sendMessage();
71
164
  };
72
- window.addEventListener('svadmin:ask-ai', handler as EventListener);
73
- return () => window.removeEventListener('svadmin:ask-ai', handler as EventListener);
74
- });
75
-
76
- // ─── Restore persisted history on mount ─────────────────────
77
- $effect(() => {
78
- if (initialized) return;
79
- initialized = true;
80
- if (onRestore) {
81
- messages = onRestore();
82
- } else if (persistKey) {
83
- try {
84
- const stored = localStorage.getItem(persistKey);
85
- if (stored) {
86
- const parsed = JSON.parse(stored) as ChatMessage[];
87
- if (Array.isArray(parsed) && parsed.length > 0) {
88
- messages = parsed;
89
- }
90
- }
91
- } catch {
92
- // ignore corrupt data
93
- }
94
- }
165
+ const eventHandler = handler as unknown as (event: globalThis.Event) => void;
166
+ window.addEventListener('svadmin:ask-ai', eventHandler);
167
+ return () => window.removeEventListener('svadmin:ask-ai', eventHandler);
95
168
  });
96
169
 
97
170
  // ─── Persist on change (debounced) ──────────────────────────
171
+ interface PersistSnapshotBase {
172
+ key: string;
173
+ messages: ChatMessage[];
174
+ }
175
+ type PersistSnapshot = PersistSnapshotBase & (
176
+ | { sink: 'callback'; callback: (messages: ChatMessage[]) => void }
177
+ | { sink: 'storage'; storage: Storage | null }
178
+ );
179
+
98
180
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
99
- let lastPersistSnapshot: ChatMessage[] | null = null;
181
+ let lastPersistSnapshot: PersistSnapshot | null = null;
182
+ let restoredHistoryScope = $state<string | null>(null);
183
+ let restoredPersistKey = $state<string | null>(null);
184
+
185
+ function getChatStorage(): Storage | null {
186
+ if (typeof window === 'undefined') return null;
187
+ try {
188
+ return window.localStorage;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
100
193
 
101
- function flushPersist(snapshot: ChatMessage[]) {
102
- if (onPersist) {
103
- onPersist(snapshot);
104
- } else if (persistKey) {
194
+ function flushPersist(snapshot: PersistSnapshot) {
195
+ if (snapshot.sink === 'callback') {
196
+ snapshot.callback(snapshot.messages);
197
+ } else if (snapshot.key) {
105
198
  try {
106
- localStorage.setItem(persistKey, JSON.stringify(snapshot));
199
+ snapshot.storage?.setItem(snapshot.key, JSON.stringify(snapshot.messages));
107
200
  } catch {
108
201
  // storage full or unavailable
109
202
  }
110
203
  }
111
204
  }
112
205
 
113
- $effect(() => {
114
- const snapshot = messages;
115
- if (!initialized) return;
206
+ function flushPendingPersist() {
207
+ if (persistTimer) {
208
+ clearTimeout(persistTimer);
209
+ persistTimer = null;
210
+ }
211
+ if (lastPersistSnapshot) {
212
+ const snapshot = lastPersistSnapshot;
213
+ lastPersistSnapshot = null;
214
+ flushPersist(snapshot);
215
+ }
216
+ }
217
+
218
+ function cancelPendingPersist() {
219
+ if (persistTimer) {
220
+ clearTimeout(persistTimer);
221
+ persistTimer = null;
222
+ }
223
+ lastPersistSnapshot = null;
224
+ }
225
+
226
+ function schedulePersist(nextMessages: ChatMessage[]) {
227
+ const callback = onPersist;
228
+ let snapshot: PersistSnapshot;
229
+ if (callback) {
230
+ // Custom persistence owns its lifecycle and must not lose early input
231
+ // while the storage-backed restore path is still becoming ready.
232
+ snapshot = {
233
+ sink: 'callback',
234
+ key: restoredPersistKey ?? resolvedPersistKey,
235
+ messages: nextMessages,
236
+ callback,
237
+ };
238
+ } else {
239
+ const restoredKey = restoredPersistKey;
240
+ if (!restoredKey || restoredKey !== resolvedPersistKey) return;
241
+ snapshot = {
242
+ sink: 'storage',
243
+ key: restoredKey,
244
+ messages: nextMessages,
245
+ storage: getChatStorage(),
246
+ };
247
+ }
116
248
 
117
249
  if (persistTimer) clearTimeout(persistTimer);
118
250
  lastPersistSnapshot = snapshot;
119
251
  persistTimer = setTimeout(() => {
120
252
  persistTimer = null;
121
- flushPersist(snapshot);
253
+ if (!lastPersistSnapshot) return;
254
+ const pendingSnapshot = lastPersistSnapshot;
255
+ lastPersistSnapshot = null;
256
+ flushPersist(pendingSnapshot);
122
257
  }, 300);
258
+ }
123
259
 
124
- return () => {
125
- if (persistTimer) {
126
- clearTimeout(persistTimer);
127
- persistTimer = null;
128
- }
260
+ function replaceMessages(nextMessages: ChatMessage[], persist = true) {
261
+ messageRevision++;
262
+ messages = nextMessages;
263
+ if (persist) schedulePersist(nextMessages);
264
+ }
265
+
266
+ function updateMessageById(
267
+ messageId: string,
268
+ update: (current: ChatMessage) => ChatMessage,
269
+ ) {
270
+ const index = messages.findIndex((message) => message.id === messageId);
271
+ if (index < 0) return;
272
+ const nextMessages = [...messages];
273
+ nextMessages[index] = update(messages[index]);
274
+ replaceMessages(nextMessages);
275
+ }
276
+
277
+ function parseStoredMessage(value: unknown): ChatMessage | null {
278
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
279
+ const candidate = value as Record<string, unknown>;
280
+ if (
281
+ typeof candidate.id !== 'string'
282
+ || (candidate.role !== 'user' && candidate.role !== 'assistant')
283
+ || typeof candidate.content !== 'string'
284
+ || typeof candidate.timestamp !== 'number'
285
+ || !Number.isFinite(candidate.timestamp)
286
+ ) return null;
287
+
288
+ // Persisted actions are capability-bearing UI state. Their callbacks do
289
+ // not survive a reload, so never revive them from mutable localStorage.
290
+ return {
291
+ id: candidate.id,
292
+ role: candidate.role,
293
+ content: candidate.content,
294
+ timestamp: candidate.timestamp,
129
295
  };
130
- });
296
+ }
131
297
 
132
- $effect(() => {
133
- return () => {
134
- if (lastPersistSnapshot != null) {
135
- flushPersist(lastPersistSnapshot);
298
+ function parseStoredMessages(value: unknown): ChatMessage[] {
299
+ if (!Array.isArray(value)) return [];
300
+ const seenIds = new Set<string>();
301
+ const restoredMessages: ChatMessage[] = [];
302
+ for (const candidate of value) {
303
+ const message = parseStoredMessage(candidate);
304
+ if (!message || seenIds.has(message.id)) continue;
305
+ seenIds.add(message.id);
306
+ restoredMessages.push(message);
307
+ }
308
+ return restoredMessages;
309
+ }
310
+
311
+ function readStoredMessages(key: string): { found: boolean; messages: ChatMessage[] } {
312
+ const storage = getChatStorage();
313
+ if (!key || !storage) return { found: false, messages: [] };
314
+ try {
315
+ const stored = storage.getItem(key);
316
+ if (stored === null) return { found: false, messages: [] };
317
+ const restored = JSON.parse(stored) as unknown;
318
+ return {
319
+ found: true,
320
+ messages: parseStoredMessages(restored),
321
+ };
322
+ } catch {
323
+ return { found: true, messages: [] };
324
+ }
325
+ }
326
+
327
+ function finishRestore(
328
+ scopeToken: string,
329
+ key: string,
330
+ restoredMessages: ChatMessage[],
331
+ persistRestoredMessages = false,
332
+ ) {
333
+ if (historyScopeToken !== scopeToken || resolvedPersistKey !== key) return;
334
+ replaceMessages(restoredMessages, false);
335
+ restoredHistoryScope = scopeToken;
336
+ restoredPersistKey = key;
337
+ if (persistRestoredMessages) schedulePersist(restoredMessages);
338
+ }
339
+
340
+ // ─── Restore persisted history for the current tenant/tree ──
341
+ $effect.pre(() => {
342
+ const scopeToken = historyScopeToken;
343
+ const key = resolvedPersistKey;
344
+ return untrack(() => {
345
+ const previousScope = restoredHistoryScope;
346
+ if (previousScope === scopeToken) return;
347
+ if (previousScope !== null) flushPendingPersist();
348
+ invalidateActiveRun({ persistApprovalRevocation: false });
349
+ restoredHistoryScope = null;
350
+ restoredPersistKey = null;
351
+ if (onRestore) {
352
+ finishRestore(scopeToken, key, onRestore(), true);
353
+ return;
136
354
  }
355
+
356
+ const current = readStoredMessages(key);
357
+ finishRestore(scopeToken, key, current.messages);
358
+ const legacyKey = persistKey === undefined ? 'svadmin-chat' : persistKey;
359
+ if (
360
+ current.found
361
+ || !key
362
+ || !legacyKey
363
+ || legacyKey === key
364
+ || adminContext.tenantCacheKey !== undefined
365
+ ) return;
366
+
367
+ // Defer migration so an immediate unmount cannot persist an empty scoped
368
+ // key. The shared historical default is consumed only by a single dialog;
369
+ // explicit historical keys already identify their panel.
370
+ const initialRevision = messageRevision;
371
+ const migrationTimer = setTimeout(() => {
372
+ if (
373
+ historyScopeToken !== scopeToken
374
+ || resolvedPersistKey !== key
375
+ || restoredHistoryScope !== scopeToken
376
+ || restoredPersistKey !== key
377
+ || messageRevision !== initialRevision
378
+ ) return;
379
+ if (
380
+ persistKey === undefined
381
+ && document.querySelectorAll('[data-svadmin-chat-scope]').length !== 1
382
+ ) return;
383
+ const legacy = readStoredMessages(legacyKey);
384
+ if (!legacy.found || legacy.messages.length === 0) return;
385
+ finishRestore(scopeToken, key, legacy.messages, true);
386
+ }, 0);
387
+ return () => clearTimeout(migrationTimer);
388
+ });
389
+ });
390
+
391
+ // Provider identity can change without changing the persistence scope. Keep
392
+ // an in-flight run and its approval capabilities bound to their origin tree.
393
+ $effect.pre(() => {
394
+ const nextRunScope = {
395
+ tenantIdentity: adminContext.tenantCacheKey?.__svadminTenant,
396
+ chatProvider: provider,
397
+ agentProvider: agent,
137
398
  };
399
+ untrack(() => {
400
+ const previous = previousRunScope;
401
+ previousRunScope = nextRunScope;
402
+ if (!previous) return;
403
+ if (
404
+ Object.is(previous.tenantIdentity, nextRunScope.tenantIdentity)
405
+ && previous.chatProvider === nextRunScope.chatProvider
406
+ && previous.agentProvider === nextRunScope.agentProvider
407
+ ) return;
408
+ invalidateActiveRun();
409
+ });
410
+ });
411
+
412
+ onDestroy(() => {
413
+ invalidateActiveRun();
414
+ flushPendingPersist();
138
415
  });
139
416
 
140
417
  function genId(): string {
@@ -152,7 +429,7 @@
152
429
 
153
430
  /** Build a system message with the current admin context */
154
431
  function buildContextSystemMessage(): ChatMessage | null {
155
- const ctx = getChatContext();
432
+ const ctx = chatContext;
156
433
  if (!ctx.currentResource) return null;
157
434
 
158
435
  const parts: string[] = [];
@@ -179,13 +456,15 @@
179
456
  timestamp: Date.now(),
180
457
  };
181
458
 
182
- messages = [...messages, userMsg];
459
+ replaceMessages([...messages, userMsg]);
183
460
  inputValue = '';
184
461
  doSend();
185
462
  }
186
463
 
187
464
  async function doSend() {
188
- if (isStreaming || (!provider && !agent)) return;
465
+ const activeAgent = agent;
466
+ const activeChatProvider = provider;
467
+ if (isStreaming || (!activeChatProvider && !activeAgent)) return;
189
468
 
190
469
  scrollToBottom();
191
470
 
@@ -196,39 +475,44 @@
196
475
  content: '',
197
476
  timestamp: Date.now(),
198
477
  };
199
- messages = [...messages, assistantMsg];
478
+ replaceMessages([...messages, assistantMsg]);
479
+ const epoch = ++runEpoch;
480
+ const controller = new AbortController();
481
+ abortController = controller;
200
482
  isStreaming = true;
201
483
  scrollToBottom();
202
484
 
203
- abortController = new AbortController();
204
-
205
485
  try {
206
486
  // Build message list with context system message prepended
207
487
  const contextMsg = buildContextSystemMessage();
208
488
  const allMessages = messages.filter((m) => m.content);
209
489
  const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
210
490
 
211
- if (agent) {
491
+ if (activeAgent) {
212
492
  // ─── AgentProvider path: typed event stream ─────────
213
- const stream = agent.chat(messagesToSend, {
214
- signal: abortController.signal,
215
- context: getChatContext(),
493
+ const stream = activeAgent.chat(messagesToSend, {
494
+ signal: controller.signal,
495
+ context: chatContext,
216
496
  });
217
497
 
218
- const collectedActions: ChatAction[] = [];
219
-
220
498
  for await (const event of stream) {
221
- if (!isStreaming) break;
499
+ if (!isCurrentRun(epoch, controller)) break;
222
500
  switch (event.type) {
223
501
  case 'text':
224
502
  assistantMsg.content += event.content;
225
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
503
+ updateMessageById(assistantMsg.id, (current) => ({
504
+ ...current,
505
+ content: assistantMsg.content,
506
+ }));
226
507
  scrollToBottom();
227
508
  break;
228
509
 
229
510
  case 'tool_call':
230
511
  assistantMsg.content += `\n🔧 *${event.tool}*(${JSON.stringify(event.args)})`;
231
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
512
+ updateMessageById(assistantMsg.id, (current) => ({
513
+ ...current,
514
+ content: assistantMsg.content,
515
+ }));
232
516
  scrollToBottom();
233
517
  break;
234
518
 
@@ -238,27 +522,28 @@
238
522
  } else {
239
523
  assistantMsg.content += `\n❌ ${event.tool} failed: ${event.result.error ?? 'Unknown error'}`;
240
524
  }
241
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
525
+ updateMessageById(assistantMsg.id, (current) => ({
526
+ ...current,
527
+ content: assistantMsg.content,
528
+ }));
242
529
  scrollToBottom();
243
530
  break;
244
531
 
245
532
  case 'approval_request': {
246
533
  const reqId = event.id;
247
- pendingApprovalIds = [...pendingApprovalIds, reqId];
248
-
249
- registerApproval(reqId, (approved) => {
250
- messages = [...messages, {
534
+ approvalCallbacks.set(reqId, (approved) => {
535
+ replaceMessages([...messages, {
251
536
  id: genId(),
252
537
  role: 'user',
253
538
  content: approved ? `User approved execution of tool '${event.tool}'` : `User rejected execution of tool '${event.tool}'`,
254
539
  timestamp: Date.now()
255
- }];
256
- if (agent?.approveToolCall) {
257
- agent.approveToolCall(reqId, approved);
540
+ }]);
541
+ if (activeAgent.approveToolCall) {
542
+ activeAgent.approveToolCall(reqId, approved);
258
543
  }
259
544
  });
260
545
 
261
- collectedActions.push(
546
+ const approvalActions: ChatAction[] = [
262
547
  {
263
548
  label: `✅ ${i18n.t('common.confirm') || 'Approve'}: ${event.description}`,
264
549
  variant: 'default',
@@ -269,18 +554,29 @@
269
554
  variant: 'destructive',
270
555
  payload: { approvalId: reqId, approved: false },
271
556
  },
272
- );
557
+ ];
273
558
 
274
559
  assistantMsg.content += `\n⚠️ **${i18n.t('chat.approvalRequired') || 'Approval required'}**: ${event.description}`;
275
- assistantMsg.actions = [...collectedActions];
276
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
560
+ updateMessageById(assistantMsg.id, (current) => ({
561
+ ...current,
562
+ content: assistantMsg.content,
563
+ actions: [
564
+ ...(current.actions ?? []).filter((candidate) => (
565
+ candidate.payload?.approvalId !== reqId
566
+ )),
567
+ ...approvalActions,
568
+ ],
569
+ }));
277
570
  scrollToBottom();
278
571
  break;
279
572
  }
280
573
 
281
574
  case 'component':
282
575
  assistantMsg.content += `\n📊 [${event.name}]`;
283
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
576
+ updateMessageById(assistantMsg.id, (current) => ({
577
+ ...current,
578
+ content: assistantMsg.content,
579
+ }));
284
580
  scrollToBottom();
285
581
  break;
286
582
 
@@ -288,56 +584,68 @@
288
584
  break;
289
585
  }
290
586
  }
291
- } else if (provider) {
587
+ } else if (activeChatProvider) {
292
588
  // ─── ChatProvider path: raw text stream ─────────────
293
- const result = provider.sendMessage(
589
+ const result = activeChatProvider.sendMessage(
294
590
  messagesToSend,
295
- { signal: abortController.signal },
591
+ { signal: controller.signal },
296
592
  );
297
593
 
298
594
  if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
299
595
  for await (const chunk of result as AsyncGenerator<string>) {
300
- if (!isStreaming) break;
596
+ if (!isCurrentRun(epoch, controller)) break;
301
597
  assistantMsg.content += chunk;
302
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
598
+ updateMessageById(assistantMsg.id, (current) => ({
599
+ ...current,
600
+ content: assistantMsg.content,
601
+ }));
303
602
  scrollToBottom();
304
603
  }
305
604
  } else {
306
605
  const text = await (result as Promise<string>);
606
+ if (!isCurrentRun(epoch, controller)) return;
307
607
  assistantMsg.content = text;
308
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
608
+ updateMessageById(assistantMsg.id, (current) => ({
609
+ ...current,
610
+ content: assistantMsg.content,
611
+ }));
309
612
  scrollToBottom();
310
613
  }
311
614
  }
312
615
  } catch (err: unknown) {
616
+ if (!isCurrentRun(epoch, controller)) return;
313
617
  if (err instanceof Error && err.name === 'AbortError') return;
314
618
  assistantMsg.content = i18n.t('chat.error') || 'Sorry, something went wrong. Please try again.';
315
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
619
+ updateMessageById(assistantMsg.id, (current) => ({
620
+ ...current,
621
+ content: assistantMsg.content,
622
+ }));
316
623
  } finally {
317
- isStreaming = false;
318
- abortController = null;
319
- scrollToBottom();
624
+ if (isCurrentRun(epoch, controller)) {
625
+ isStreaming = false;
626
+ abortController = null;
627
+ flushPendingPersist();
628
+ scrollToBottom();
629
+ }
320
630
  }
321
631
  }
322
632
 
323
633
  function stopStreaming() {
324
- if (abortController) {
325
- abortController.abort();
326
- isStreaming = false;
327
- abortController = null;
328
- }
634
+ if (!abortController && !isStreaming) return;
635
+ invalidateActiveRun();
636
+ flushPendingPersist();
329
637
  }
330
638
 
331
639
  function clearChat() {
332
- messages = [];
333
- isStreaming = false;
334
- if (abortController) {
335
- abortController.abort();
336
- abortController = null;
337
- }
338
- // Also clear persisted data
339
- if (persistKey) {
340
- try { localStorage.removeItem(persistKey); } catch { /* noop */ }
640
+ invalidateActiveRun();
641
+ cancelPendingPersist();
642
+ replaceMessages([], false);
643
+ const key = restoredPersistKey ?? resolvedPersistKey;
644
+ const callback = onPersist;
645
+ if (callback) {
646
+ flushPersist({ sink: 'callback', key, messages: [], callback });
647
+ } else if (key) {
648
+ try { getChatStorage()?.removeItem(key); } catch { /* noop */ }
341
649
  }
342
650
  }
343
651
 
@@ -350,6 +658,20 @@
350
658
 
351
659
  function handleGlobalKeydown(e: KeyboardEvent) {
352
660
  if (e.ctrlKey && e.shiftKey && e.key === 'L') {
661
+ const eventTarget = e.target instanceof HTMLElement ? e.target : document.activeElement as HTMLElement | null;
662
+ const eventLayoutScope = eventTarget
663
+ ?.closest<HTMLElement>('[data-svadmin-layout-scope]')
664
+ ?.dataset.svadminLayoutScope;
665
+ const layoutCount = document.querySelectorAll('[data-svadmin-layout-scope]').length;
666
+ const activeChatScope = eventTarget
667
+ ?.closest<HTMLElement>('[data-svadmin-chat-scope]')
668
+ ?.dataset.svadminChatScope;
669
+ const chatCount = document.querySelectorAll('[data-svadmin-chat-scope]').length;
670
+ const ownsShortcut = ownerScope
671
+ ? (eventLayoutScope ? eventLayoutScope === ownerScope : layoutCount === 1)
672
+ : (activeChatScope ? activeChatScope === componentScope : chatCount === 1);
673
+ if (!ownsShortcut) return;
674
+
353
675
  e.preventDefault();
354
676
  if (open && !minimized) {
355
677
  open = false;
@@ -365,11 +687,18 @@
365
687
  if (action.payload?.approvalId) {
366
688
  const id = action.payload.approvalId as string;
367
689
  const approved = action.payload.approved as boolean;
368
- resolveApproval(id, approved);
369
- pendingApprovalIds = pendingApprovalIds.filter(pid => pid !== id);
370
- // Remove action buttons after resolution
371
- msg.actions = undefined;
372
- messages = [...messages];
690
+ const resolve = approvalCallbacks.get(id);
691
+ approvalCallbacks.delete(id);
692
+ updateMessageById(msg.id, (current) => {
693
+ const remainingActions = current.actions?.filter((candidate) => (
694
+ candidate.payload?.approvalId !== id
695
+ ));
696
+ return {
697
+ ...current,
698
+ actions: remainingActions?.length ? remainingActions : undefined,
699
+ };
700
+ });
701
+ resolve?.(approved);
373
702
  return;
374
703
  }
375
704
  if (onAction) {
@@ -416,6 +745,11 @@
416
745
  <svelte:window onkeydown={handleGlobalKeydown} />
417
746
 
418
747
  {#if provider || agent}
748
+ <div
749
+ class="contents"
750
+ data-svadmin-chat-scope={componentScope}
751
+ data-svadmin-chat-visible={open ? 'true' : 'false'}
752
+ >
419
753
  <!-- FAB Button -->
420
754
  {#if !open}
421
755
  <div transition:scale={{ duration: 200 }}>
@@ -609,5 +943,5 @@
609
943
  {/if}
610
944
  </div>
611
945
  {/if}
946
+ </div>
612
947
  {/if}
613
-