@djangocfg/ui-tools 2.1.409 → 2.1.412

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 (83) hide show
  1. package/package.json +13 -13
  2. package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
  3. package/src/lib/browser-bridge/commands/chat.ts +42 -0
  4. package/src/lib/browser-bridge/commands/highlight.ts +70 -0
  5. package/src/lib/browser-bridge/commands/index.ts +15 -0
  6. package/src/lib/browser-bridge/commands/inspect.ts +31 -0
  7. package/src/lib/browser-bridge/commands/scroll.ts +31 -0
  8. package/src/lib/browser-bridge/commands/write.ts +45 -0
  9. package/src/lib/browser-bridge/directive-bus.ts +120 -0
  10. package/src/lib/browser-bridge/index.ts +56 -0
  11. package/src/lib/browser-bridge/logger.ts +27 -0
  12. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
  13. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
  14. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
  15. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
  16. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
  17. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
  18. package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
  19. package/src/lib/browser-bridge/registry.ts +41 -0
  20. package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
  21. package/src/lib/browser-bridge/window.ts +76 -0
  22. package/src/lib/page-snapshot/capture/walk.ts +13 -5
  23. package/src/lib/page-snapshot/engine.ts +9 -4
  24. package/src/lib/page-snapshot/index.ts +5 -0
  25. package/src/lib/page-snapshot/react/provider.tsx +70 -3
  26. package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
  27. package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
  28. package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
  29. package/src/lib/page-snapshot/refs/locator.ts +218 -0
  30. package/src/lib/page-snapshot/refs/registry.ts +29 -14
  31. package/src/tools/Chat/README.md +1 -1
  32. package/src/tools/Chat/composer/AttachContext.tsx +22 -0
  33. package/src/tools/Chat/composer/Composer.tsx +108 -6
  34. package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
  35. package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
  36. package/src/tools/Chat/composer/index.ts +16 -1
  37. package/src/tools/Chat/composer/types.ts +71 -0
  38. package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
  39. package/src/tools/Chat/constants.ts +24 -1
  40. package/src/tools/Chat/context/ChatProvider.tsx +17 -2
  41. package/src/tools/Chat/core/logger.ts +15 -2
  42. package/src/tools/Chat/hooks/useChat.ts +32 -0
  43. package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
  44. package/src/tools/Chat/index.ts +34 -2
  45. package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
  46. package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
  47. package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
  48. package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
  49. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
  50. package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
  51. package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
  52. package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
  53. package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
  54. package/src/tools/Chat/lazy.tsx +34 -2
  55. package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
  56. package/src/tools/Chat/public.ts +17 -0
  57. package/src/tools/Chat/settings/README.md +87 -0
  58. package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
  59. package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
  60. package/src/tools/Chat/settings/index.ts +23 -0
  61. package/src/tools/Chat/settings/types.ts +108 -0
  62. package/src/tools/Chat/settings/useChatSettings.ts +168 -0
  63. package/src/tools/Chat/types/events.ts +50 -0
  64. package/src/tools/Chat/types/index.ts +1 -1
  65. package/src/tools/Chat/types/message.ts +5 -0
  66. package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
  67. package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
  68. package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
  69. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
  70. package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
  71. package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
  72. package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
  73. package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
  74. package/src/tools/CronScheduler/context/hooks.ts +8 -0
  75. package/src/tools/CronScheduler/context/index.ts +1 -0
  76. package/src/tools/CronScheduler/index.tsx +2 -0
  77. package/src/tools/CronScheduler/lazy.tsx +1 -0
  78. package/src/tools/CronScheduler/types/index.ts +18 -1
  79. package/src/tools/Map/lazy.tsx +11 -4
  80. package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
  81. package/src/tools/index.ts +2 -0
  82. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
  83. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
@@ -12,7 +12,30 @@ export const CSS_VARS = {
12
12
  reserve: '--djc-chat-reserve',
13
13
  } as const;
14
14
 
15
- export const DEFAULT_Z_INDEX = 9000;
15
+ /**
16
+ * Z-index tier for the floating chat surface.
17
+ *
18
+ * The chat dock is page furniture — a peer of page content, not a modal.
19
+ * It must sit ABOVE the page yet BELOW every `@djangocfg/ui-core` overlay
20
+ * (sheet 200, drawer 500, dialog 600, anchored overlays 700) so that any
21
+ * dialog — including one launched from inside the chat — always wins.
22
+ *
23
+ * dock 100 the chat surface
24
+ * companion 99 FAB / greeting / unread preview (behind the open dock)
25
+ * tooltip 101 chat-header tooltips (just above the dock, still
26
+ * below ui-core's modal/overlay tiers)
27
+ */
28
+ export const Z_INDEX = {
29
+ /** The chat dock surface. */
30
+ dock: 100,
31
+ /** FAB, greeting bubble and unread preview — sit just below the dock. */
32
+ companion: 99,
33
+ /** Tooltips portaled out of the chat header — just above the dock. */
34
+ tooltip: 101,
35
+ } as const;
36
+
37
+ /** @deprecated Use {@link Z_INDEX.dock}. Kept for the public API surface. */
38
+ export const DEFAULT_Z_INDEX = Z_INDEX.dock;
16
39
 
17
40
  export const LIMITS = {
18
41
  /** Max characters per single message. */
@@ -15,6 +15,7 @@ import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
15
15
  import { DEFAULT_LABELS } from '../types';
16
16
  import type { BlockRegistry } from '../messages/blocks';
17
17
 
18
+ import { setBridgeSender } from '../../../lib/browser-bridge';
18
19
  import { useChat, type UseChatReturn } from '../hooks/useChat';
19
20
  import { useChatLayout, type UseChatLayoutReturn } from '../hooks/useChatLayout';
20
21
  import { useChatAudio } from '../hooks/useChatAudio';
@@ -212,14 +213,28 @@ export function ChatProvider({
212
213
  };
213
214
  }, [audioApi]);
214
215
 
216
+ // Publish `sendMessage` to the browser bridge so the dev-only
217
+ // `window.__chatBridge.sendMessage(...)` can drive the chat from the
218
+ // console. `setBridgeSender` no-ops outside dev; cleared on unmount.
219
+ useEffect(() => {
220
+ setBridgeSender(chat.sendMessage);
221
+ return () => setBridgeSender(null);
222
+ }, [chat.sendMessage]);
223
+
215
224
  const labels = useMemo<ChatLabels>(
216
225
  () => ({ ...DEFAULT_LABELS, ...(config.labels ?? {}) }),
217
226
  [config.labels],
218
227
  );
219
228
 
229
+ // True when audio is wired up — drives the auto-injected mute toggle.
230
+ // `audio={{}}` counts: `useChatAudio` falls back to the bundled
231
+ // DEFAULT_CHAT_SOUNDS, so an empty config still has sounds to mute.
232
+ // Only an absent `audio` prop or `silenced` means "nothing to mute".
220
233
  const hasAudio = useMemo<boolean>(() => {
221
- const sounds = audio?.sounds;
222
- if (!sounds) return false;
234
+ if (!audio) return false;
235
+ if (audio.silenced) return false;
236
+ const sounds = audio.sounds;
237
+ if (sounds === undefined) return true;
223
238
  return Object.values(sounds).some(
224
239
  (v) => typeof v === 'string' && v.length > 0,
225
240
  );
@@ -22,7 +22,13 @@ import { consola, type ConsolaInstance } from 'consola';
22
22
 
23
23
  import { isDev } from '@djangocfg/ui-core/lib';
24
24
 
25
- export type ChatLogScope = 'bootstrap' | 'transport' | 'stream' | 'lifecycle' | 'tools' | 'error';
25
+ export type ChatLogScope =
26
+ | 'bootstrap'
27
+ | 'transport'
28
+ | 'stream'
29
+ | 'lifecycle'
30
+ | 'tools'
31
+ | 'error';
26
32
 
27
33
  export interface ChatLogger {
28
34
  bootstrap: ConsolaInstance;
@@ -35,7 +41,14 @@ export interface ChatLogger {
35
41
  enabled: boolean;
36
42
  }
37
43
 
38
- const SCOPES: ChatLogScope[] = ['bootstrap', 'transport', 'stream', 'lifecycle', 'tools', 'error'];
44
+ const SCOPES: ChatLogScope[] = [
45
+ 'bootstrap',
46
+ 'transport',
47
+ 'stream',
48
+ 'lifecycle',
49
+ 'tools',
50
+ 'error',
51
+ ];
39
52
 
40
53
  /** Module-level cache so all hooks/components share the same logger instance per `enabled` mode. */
41
54
  const cache = new Map<boolean, ChatLogger>();
@@ -407,6 +407,38 @@ export function useChat(config: UseChatConfig): UseChatReturn {
407
407
  sources: ev.sources?.length ?? 0,
408
408
  });
409
409
  return;
410
+ case 'message_metrics':
411
+ // Non-terminal: attach per-turn metrics to the streaming
412
+ // message. The stream stays open until message_end / error.
413
+ dispatch({
414
+ type: 'MESSAGE_PATCH',
415
+ id: targetId,
416
+ patch: {
417
+ metrics: ev.metrics,
418
+ ...(ev.metrics.resolvedModel
419
+ ? { resolvedModel: ev.metrics.resolvedModel }
420
+ : {}),
421
+ },
422
+ });
423
+ log.stream.debug('message_metrics', {
424
+ turns: ev.metrics.turns,
425
+ toolCallCount: ev.metrics.toolCallCount,
426
+ resolvedModel: ev.metrics.resolvedModel,
427
+ });
428
+ return;
429
+ case 'resolved_model':
430
+ // Non-terminal: model alias was resolved mid-run.
431
+ dispatch({
432
+ type: 'MESSAGE_PATCH',
433
+ id: targetId,
434
+ patch: { resolvedModel: ev.resolvedModel },
435
+ });
436
+ log.stream.debug('resolved_model', {
437
+ originalAlias: ev.originalAlias,
438
+ resolvedModel: ev.resolvedModel,
439
+ upgraded: ev.upgraded,
440
+ });
441
+ return;
410
442
  case 'error':
411
443
  tokenBuffer.flush();
412
444
  dispatch({
@@ -50,6 +50,9 @@ export interface UseChatComposerReturn {
50
50
  setValue: (next: string) => void;
51
51
  attachments: ChatAttachment[];
52
52
  addAttachment: (a: ChatAttachment) => void;
53
+ /** Patch an existing attachment in place — used by the upload lifecycle
54
+ * to flip `status` / `progress` / `url` without losing list order. */
55
+ updateAttachment: (id: string, patch: Partial<ChatAttachment>) => void;
53
56
  removeAttachment: (id: string) => void;
54
57
  isSubmitting: boolean;
55
58
  canSubmit: boolean;
@@ -192,6 +195,15 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
192
195
  [maxAttachments],
193
196
  );
194
197
 
198
+ const updateAttachment = useCallback(
199
+ (id: string, patch: Partial<ChatAttachment>) => {
200
+ setAttachments((prev) =>
201
+ prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
202
+ );
203
+ },
204
+ [],
205
+ );
206
+
195
207
  const removeAttachment = useCallback((id: string) => {
196
208
  setAttachments((prev) => prev.filter((a) => a.id !== id));
197
209
  }, []);
@@ -276,6 +288,7 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
276
288
  setValue,
277
289
  attachments,
278
290
  addAttachment,
291
+ updateAttachment,
279
292
  removeAttachment,
280
293
  isSubmitting,
281
294
  canSubmit,
@@ -18,12 +18,24 @@ export * from './messages';
18
18
  export * from './composer';
19
19
  export * from './shell';
20
20
 
21
- // Highlight — AI-driven `point` directives (spotlight + focus overlay).
21
+ // Bridge — AI-driven `point` directives (spotlight + focus overlay).
22
22
  export {
23
23
  HighlightOverlay,
24
24
  SpotlightCanvas,
25
25
  useHighlightTargets,
26
26
  resolveRefs,
27
+ useChatDirectives,
28
+ pushDirectives,
29
+ clearDirectives,
30
+ parseDirectives,
31
+ installChatBridge,
32
+ registerBridgeCommand,
33
+ getBridgeCommand,
34
+ setBridgeResolver,
35
+ setBridgeSender,
36
+ type BridgeCommand,
37
+ type BridgeSender,
38
+ type ChatBridge,
27
39
  type HighlightOverlayProps,
28
40
  type SpotlightCanvasProps,
29
41
  type RefResolver,
@@ -31,7 +43,27 @@ export {
31
43
  type HighlightTarget,
32
44
  type SpotlightRect,
33
45
  type CSTRefId,
34
- } from './highlight';
46
+ } from '../../lib/browser-bridge';
47
+
48
+ // Page-context snapshot — capture engine + React surface. The host
49
+ // wires `getChatMetadata` into `getDynamicMetadata` so the assistant
50
+ // receives a snapshot of the page the user is looking at.
51
+ export {
52
+ PageSnapshotProvider,
53
+ usePageSnapshot,
54
+ usePageSnapshotToggle,
55
+ PageSnapshotChip,
56
+ PageSnapshotPreview,
57
+ PageSnapshotEngine,
58
+ type PageSnapshotProviderProps,
59
+ type PageSnapshotContextValue,
60
+ type PageSnapshotToggle,
61
+ type PageSnapshotChipProps,
62
+ type PageSnapshotPreviewProps,
63
+ type CaptureEngineOptions,
64
+ type CaptureResult,
65
+ type PageContextPayload,
66
+ } from '../../lib/page-snapshot';
35
67
 
36
68
  // Launcher — FAB + Dock + Header + Greeting composition (heavy, sync).
37
69
  export {
@@ -7,6 +7,7 @@ import { Portal } from '@djangocfg/ui-core/components';
7
7
  import { useIsMobile, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
8
8
  import { cn } from '@djangocfg/ui-core/lib';
9
9
 
10
+ import { Z_INDEX } from '../constants';
10
11
  import { ChatHeader } from './header';
11
12
  import { useChatPresence } from './useChatPresence';
12
13
  import type { ChatFABPosition } from './ChatFAB';
@@ -52,7 +53,11 @@ export interface ChatDockProps {
52
53
  offset?: { horizontal?: number; vertical?: number };
53
54
  /** Transition duration in ms — should match CSS animation. @default 200 */
54
55
  exitDurationMs?: number;
55
- /** z-index. @default 10000 */
56
+ /**
57
+ * z-index. @default 100 — page furniture: above page content but below
58
+ * every ui-core overlay (sheet/drawer/dialog/popover), so a dialog always
59
+ * covers the chat, including one opened from inside the chat itself.
60
+ */
56
61
  zIndex?: number;
57
62
  /** Accessible dialog label. */
58
63
  ariaLabel?: string;
@@ -118,7 +123,7 @@ export function ChatDock({
118
123
  position = 'bottom-right',
119
124
  offset,
120
125
  exitDurationMs = 200,
121
- zIndex = 10000,
126
+ zIndex = Z_INDEX.dock,
122
127
  ariaLabel,
123
128
  className,
124
129
  mobileFullscreen = true,
@@ -236,7 +241,12 @@ export function ChatDock({
236
241
  <div
237
242
  role="dialog"
238
243
  aria-label={ariaLabel ?? (typeof title === 'string' ? title : 'Chat')}
239
- aria-hidden={phase === 'leaving'}
244
+ // While leaving, mark the panel `inert` rather than `aria-hidden`.
245
+ // `aria-hidden` on an ancestor of the still-focused element (e.g.
246
+ // the close button just clicked, or the composer) trips the
247
+ // browser's "Blocked aria-hidden ... descendant retained focus"
248
+ // warning. `inert` hides it from the a11y tree AND blanks focus.
249
+ inert={phase === 'leaving' ? true : undefined}
240
250
  className={cn(
241
251
  'bg-popover text-popover-foreground border-border',
242
252
  'flex flex-col overflow-hidden shadow-2xl',
@@ -6,6 +6,8 @@ import type { CSSProperties, ReactNode } from 'react';
6
6
  import { useIsPhone, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
7
7
  import { cn } from '@djangocfg/ui-core/lib';
8
8
 
9
+ import { Z_INDEX } from '../constants';
10
+
9
11
  export type ChatFABPosition =
10
12
  | 'bottom-right'
11
13
  | 'bottom-left'
@@ -30,7 +32,7 @@ export interface ChatFABProps {
30
32
  position?: ChatFABPosition;
31
33
  /** Pixel offset from screen edges. @default 24 */
32
34
  offset?: number;
33
- /** z-index for the button. @default 9999 */
35
+ /** z-index for the button. @default 99 — just below the open dock. */
34
36
  zIndex?: number;
35
37
  /** Show a small attention dot (unread / new). */
36
38
  pulse?: boolean;
@@ -145,7 +147,7 @@ export function ChatFAB({
145
147
  size = 'responsive',
146
148
  position = 'bottom-right',
147
149
  offset = 24,
148
- zIndex = 9999,
150
+ zIndex = Z_INDEX.companion,
149
151
  pulse = false,
150
152
  badge,
151
153
  tooltip,
@@ -6,6 +6,7 @@ import type { CSSProperties, ReactNode } from 'react';
6
6
 
7
7
  import { cn } from '@djangocfg/ui-core/lib';
8
8
 
9
+ import { Z_INDEX } from '../constants';
9
10
  import { useChatPresence } from './useChatPresence';
10
11
  import type { ChatFABPosition } from './ChatFAB';
11
12
 
@@ -32,7 +33,7 @@ export interface ChatGreetingProps {
32
33
  fabClearance?: number;
33
34
  /** Delay before the greeting appears, in ms. @default 1500 */
34
35
  delayMs?: number;
35
- /** z-index. @default 9998 (just below the default FAB at 9999). */
36
+ /** z-index. @default 99 — companion tier, just below the open dock. */
36
37
  zIndex?: number;
37
38
  /** Override classes on the bubble. */
38
39
  className?: string;
@@ -110,7 +111,7 @@ export function ChatGreeting({
110
111
  fabOffset = 24,
111
112
  fabClearance = 96,
112
113
  delayMs = 1500,
113
- zIndex = 9998,
114
+ zIndex = Z_INDEX.companion,
114
115
  className,
115
116
  style,
116
117
  avatar,
@@ -7,6 +7,7 @@ import { useHotkey } from '@djangocfg/ui-core/hooks';
7
7
 
8
8
  import { ChatProvider, useChatContextOptional } from '../context';
9
9
  import type { ChatAudioConfig } from '../core/audio/types';
10
+ import { useChatDockPrefs } from '../hooks/useChatDockPrefs';
10
11
  import type {
11
12
  ChatConfig,
12
13
  ChatMessage,
@@ -275,10 +276,21 @@ export function ChatLauncher({
275
276
  ? { ...fab, badge: 1 }
276
277
  : fab;
277
278
 
278
- // Whether the audio prop wires up any actual sound. Used as the
279
- // default for `headerSlots.audio` no point auto-injecting the
280
- // toggle when there's nothing to mute.
279
+ // Reused below for the ambient-provider branch; read up here so
280
+ // `audioConfigured` can fall back to the ambient provider's audio.
281
+ const ambient = useChatContextOptional();
282
+
283
+ // Whether audio wires up any actual sound. Used as the default for
284
+ // `headerSlots.audio` — no point auto-injecting the toggle when
285
+ // there's nothing to mute.
286
+ //
287
+ // When the launcher is rendered inside an existing `<ChatProvider>`
288
+ // (the host owns the provider), audio was configured there, not via
289
+ // this component's `audio` prop — so honor the ambient provider's
290
+ // `hasAudio` too. Without this the dock-header mute toggle silently
291
+ // never appears for host-owned-provider setups.
281
292
  const audioConfigured = useMemo<boolean>(() => {
293
+ if (ambient?.hasAudio) return true;
282
294
  if (!audio) return false;
283
295
  if (audio.silenced) return false;
284
296
  const sounds = audio.sounds;
@@ -288,13 +300,25 @@ export function ChatLauncher({
288
300
  return Object.values(sounds).some(
289
301
  (v) => typeof v === 'string' && v.length > 0,
290
302
  );
291
- }, [audio]);
303
+ }, [audio, ambient?.hasAudio]);
292
304
 
293
305
  const resolvedSlots = useMemo(
294
306
  () => resolveHeaderSlots(headerSlots, audioConfigured),
295
307
  [headerSlots, audioConfigured],
296
308
  );
297
309
 
310
+ // Single source of truth for dock layout prefs. The mode-toggle slot
311
+ // and `<ChatDock>` live in separate subtrees — if each called
312
+ // `useChatDockPrefs` independently they'd hold isolated `useState`
313
+ // (no same-tab cross-instance sync), so the toggle would flip
314
+ // localStorage but never re-render the dock. Owning the hook here and
315
+ // threading both the values and the toggle down keeps them in sync.
316
+ const modeToggleSlot = resolvedSlots.modeToggle;
317
+ const dockPrefs = useChatDockPrefs({
318
+ storageKey: modeToggleSlot?.persistAs,
319
+ defaults: modeToggleSlot?.defaults,
320
+ });
321
+
298
322
  const hasAnySlot =
299
323
  resolvedSlots.audio ||
300
324
  resolvedSlots.modeToggle !== null ||
@@ -302,8 +326,6 @@ export function ChatLauncher({
302
326
  resolvedSlots.reset !== null ||
303
327
  resolvedSlots.custom !== null;
304
328
 
305
- const ambient = useChatContextOptional();
306
-
307
329
  const body = (
308
330
  <>
309
331
  <ChatFAB {...resolvedFab} onClick={toggleOpen} />
@@ -331,10 +353,23 @@ export function ChatLauncher({
331
353
  ) : null}
332
354
  <ChatDock
333
355
  {...dock}
356
+ // When the mode-toggle slot is active, dock prefs are the source
357
+ // of truth so the header toggle actually re-docks the panel.
358
+ // Otherwise fall back to the static `dock` props. `sideWidth`
359
+ // only governs side mode — leave popover width to `dock.width`.
360
+ mode={modeToggleSlot ? dockPrefs.mode : dock?.mode}
361
+ side={modeToggleSlot ? dockPrefs.side : dock?.side}
362
+ width={
363
+ modeToggleSlot && dockPrefs.mode === 'side'
364
+ ? dockPrefs.sideWidth
365
+ : dock?.width
366
+ }
334
367
  open={open}
335
368
  onClose={() => setOpen(false)}
336
369
  headerActions={
337
- hasAnySlot ? <HeaderSlotsRenderer slots={resolvedSlots} /> : undefined
370
+ hasAnySlot ? (
371
+ <HeaderSlotsRenderer slots={resolvedSlots} dockPrefs={dockPrefs} />
372
+ ) : undefined
338
373
  }
339
374
  >
340
375
  <div ref={dockContentRef} className="flex h-full min-h-0 min-w-0 flex-col">
@@ -6,6 +6,7 @@ import type { CSSProperties, ReactNode } from 'react';
6
6
  import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components';
7
7
  import { cn } from '@djangocfg/ui-core/lib';
8
8
 
9
+ import { Z_INDEX } from '../constants';
9
10
  import type { ChatMessage, ChatPersona } from '../types';
10
11
  import type { ChatFABPosition } from './ChatFAB';
11
12
  import { useChatPresence } from './useChatPresence';
@@ -27,7 +28,7 @@ export interface ChatUnreadPreviewProps {
27
28
  fabClearance?: number;
28
29
  /** Lines of body text before ellipsis. @default 2 */
29
30
  truncate?: number;
30
- /** z-index. @default 9998 */
31
+ /** z-index. @default 99 — companion tier, just below the open dock. */
31
32
  zIndex?: number;
32
33
  /** Render in-place (stories / previews). @default false */
33
34
  inline?: boolean;
@@ -100,7 +101,7 @@ export function ChatUnreadPreview({
100
101
  fabOffset = 24,
101
102
  fabClearance = 96,
102
103
  truncate = 2,
103
- zIndex = 9998,
104
+ zIndex = Z_INDEX.companion,
104
105
  inline = false,
105
106
  className,
106
107
  style,
@@ -79,6 +79,8 @@ export function ChatHeader({
79
79
  <X className="h-4 w-4" />
80
80
  </Button>
81
81
  </TooltipTrigger>
82
+ {/* Tooltip portals to <body> in ui-core's anchored-overlay
83
+ tier, already above the dock — no override needed. */}
82
84
  <TooltipContent side="bottom">{closeLabel}</TooltipContent>
83
85
  </Tooltip>
84
86
  </TooltipProvider>
@@ -110,6 +110,8 @@ export const ChatHeaderActionButton = forwardRef<HTMLButtonElement, ChatHeaderAc
110
110
  <TooltipProvider delayDuration={300}>
111
111
  <Tooltip>
112
112
  <TooltipTrigger asChild>{button}</TooltipTrigger>
113
+ {/* Tooltip portals to <body> in ui-core's anchored-overlay tier,
114
+ already above the dock — no z-index override needed. */}
113
115
  <TooltipContent side="bottom">{tooltipLabel}</TooltipContent>
114
116
  </Tooltip>
115
117
  </TooltipProvider>
@@ -99,9 +99,9 @@ export function ChatHeaderLanguageButton({
99
99
  );
100
100
  }}
101
101
  // Popover width follows the trigger by default (28px → narrow).
102
- // Force a usable width and bump z-index above ChatDock (z-10000).
102
+ // Force a usable width; the popover already portals into ui-core's
103
+ // anchored-overlay tier, above the dock — no z-index override needed.
103
104
  contentClassName="w-[280px]"
104
- contentStyle={{ zIndex: 10001 }}
105
105
  // Custom row: country flag + native language label + BCP-47 tag.
106
106
  // ui-core Combobox default rendering only shows label/description
107
107
  // — feeding the flag here keeps every list row instantly
@@ -3,7 +3,7 @@
3
3
  import { Fragment } from 'react';
4
4
 
5
5
  import { useChatContext } from '../../context';
6
- import { useChatDockPrefs } from '../../hooks/useChatDockPrefs';
6
+ import type { UseChatDockPrefsReturn } from '../../hooks/useChatDockPrefs';
7
7
 
8
8
  import { ChatHeaderAudioToggle } from './ChatHeaderAudioToggle';
9
9
  import { ChatHeaderLanguageButton } from './ChatHeaderLanguageButton';
@@ -13,6 +13,13 @@ import type { ResolvedChatHeaderSlots } from '../types';
13
13
 
14
14
  export interface HeaderSlotsRendererProps {
15
15
  slots: ResolvedChatHeaderSlots;
16
+ /**
17
+ * Shared dock-prefs instance owned by `<ChatLauncher>`. The mode
18
+ * toggle MUST use the same instance the dock reads from — a separate
19
+ * `useChatDockPrefs` call would hold isolated state and the toggle
20
+ * would no-op.
21
+ */
22
+ dockPrefs: UseChatDockPrefsReturn;
16
23
  }
17
24
 
18
25
  /**
@@ -22,7 +29,7 @@ export interface HeaderSlotsRendererProps {
22
29
  * Order (left → right, before the close icon):
23
30
  * custom · languagePicker · modeToggle · audio · reset
24
31
  */
25
- export function HeaderSlotsRenderer({ slots }: HeaderSlotsRendererProps) {
32
+ export function HeaderSlotsRenderer({ slots, dockPrefs }: HeaderSlotsRendererProps) {
26
33
  const ctx = useChatContext();
27
34
  return (
28
35
  <>
@@ -34,7 +41,9 @@ export function HeaderSlotsRenderer({ slots }: HeaderSlotsRendererProps) {
34
41
  hideFallbackIcon={slots.languagePicker.hideFallbackIcon}
35
42
  />
36
43
  ) : null}
37
- {slots.modeToggle ? <ModeToggleSlot slot={slots.modeToggle} /> : null}
44
+ {slots.modeToggle ? (
45
+ <ModeToggleSlot slot={slots.modeToggle} dockPrefs={dockPrefs} />
46
+ ) : null}
38
47
  {slots.audio && !ctx.audio.isSilent ? (
39
48
  <ChatHeaderAudioToggle
40
49
  muted={ctx.audio.muted}
@@ -48,17 +57,15 @@ export function HeaderSlotsRenderer({ slots }: HeaderSlotsRendererProps) {
48
57
 
49
58
  function ModeToggleSlot({
50
59
  slot,
60
+ dockPrefs,
51
61
  }: {
52
62
  slot: NonNullable<ResolvedChatHeaderSlots['modeToggle']>;
63
+ dockPrefs: UseChatDockPrefsReturn;
53
64
  }) {
54
- const prefs = useChatDockPrefs({
55
- storageKey: slot.persistAs,
56
- defaults: slot.defaults,
57
- });
58
65
  return (
59
66
  <ChatHeaderModeToggle
60
- mode={prefs.mode}
61
- onToggle={prefs.toggleMode}
67
+ mode={dockPrefs.mode}
68
+ onToggle={dockPrefs.toggleMode}
62
69
  forceVisible={slot.forceVisible ?? true}
63
70
  expandLabel={slot.expandLabel}
64
71
  collapseLabel={slot.collapseLabel}
@@ -75,12 +75,24 @@ export * from './composer';
75
75
  export * from './shell';
76
76
  export * from './launcher';
77
77
 
78
- // Highlight — AI-driven `point` directives (lightweight, no heavy deps).
78
+ // Bridge — AI-driven `point` directives (lightweight, no heavy deps).
79
79
  export {
80
80
  HighlightOverlay,
81
81
  SpotlightCanvas,
82
82
  useHighlightTargets,
83
83
  resolveRefs,
84
+ useChatDirectives,
85
+ pushDirectives,
86
+ clearDirectives,
87
+ parseDirectives,
88
+ installChatBridge,
89
+ registerBridgeCommand,
90
+ getBridgeCommand,
91
+ setBridgeResolver,
92
+ setBridgeSender,
93
+ type BridgeCommand,
94
+ type BridgeSender,
95
+ type ChatBridge,
84
96
  type HighlightOverlayProps,
85
97
  type SpotlightCanvasProps,
86
98
  type RefResolver,
@@ -88,4 +100,24 @@ export {
88
100
  type HighlightTarget,
89
101
  type SpotlightRect,
90
102
  type CSTRefId,
91
- } from './highlight';
103
+ } from '../../lib/browser-bridge';
104
+
105
+ // Page-context snapshot — capture engine + React surface. The host
106
+ // wires `getChatMetadata` into `getDynamicMetadata` so the assistant
107
+ // receives a snapshot of the page the user is looking at.
108
+ export {
109
+ PageSnapshotProvider,
110
+ usePageSnapshot,
111
+ usePageSnapshotToggle,
112
+ PageSnapshotChip,
113
+ PageSnapshotPreview,
114
+ PageSnapshotEngine,
115
+ type PageSnapshotProviderProps,
116
+ type PageSnapshotContextValue,
117
+ type PageSnapshotToggle,
118
+ type PageSnapshotChipProps,
119
+ type PageSnapshotPreviewProps,
120
+ type CaptureEngineOptions,
121
+ type CaptureResult,
122
+ type PageContextPayload,
123
+ } from '../../lib/page-snapshot';
@@ -264,7 +264,7 @@ const MessageBubbleInner = ({
264
264
  {message.blocks?.length ? (
265
265
  <MessageBlocks
266
266
  blocks={message.blocks}
267
- registry={resolvedBlockRegistry}
267
+ registry={resolvedBlockRegistry ?? undefined}
268
268
  appearance={appearance}
269
269
  isUser={isUser}
270
270
  />
@@ -34,6 +34,7 @@ export type {
34
34
  ChatLabels,
35
35
  ChatTransport,
36
36
  ChatStreamEvent,
37
+ ChatMessageMetrics,
37
38
  CreateSessionOptions,
38
39
  SessionInfo,
39
40
  HistoryPage,
@@ -61,6 +62,7 @@ export { DEFAULT_LABELS } from './types';
61
62
  export {
62
63
  STORAGE_KEYS,
63
64
  CSS_VARS,
65
+ Z_INDEX,
64
66
  DEFAULT_Z_INDEX,
65
67
  LIMITS,
66
68
  DEFAULT_SIDEBAR,
@@ -151,6 +153,21 @@ export {
151
153
  type UseChatAudioReturn,
152
154
  } from './core/audio';
153
155
 
156
+ // Settings — the single, centralized home for chat-owned persisted
157
+ // settings. Every chat feature reads/writes its slice through this hook.
158
+ export {
159
+ useChatSettings,
160
+ CHAT_SETTINGS_STORAGE_KEY,
161
+ DEFAULT_CHAT_SETTINGS,
162
+ type ChatSettings,
163
+ type ChatDockSettings,
164
+ type ChatAudioSettings,
165
+ type ChatSpeechSettings,
166
+ type ChatPageContextSettings,
167
+ type UseChatSettingsOptions,
168
+ type UseChatSettingsReturn,
169
+ } from './settings';
170
+
154
171
  // Notifier — title rotation + favicon badge + page-visibility + cross-tab
155
172
  export {
156
173
  createBrowserNotifier,