@djangocfg/ui-tools 2.1.409 → 2.1.411

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 (54) 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/constants.ts +24 -1
  33. package/src/tools/Chat/context/ChatProvider.tsx +17 -2
  34. package/src/tools/Chat/core/logger.ts +15 -2
  35. package/src/tools/Chat/index.ts +34 -2
  36. package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
  37. package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
  38. package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
  39. package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
  40. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
  41. package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
  42. package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
  43. package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
  44. package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
  45. package/src/tools/Chat/lazy.tsx +34 -2
  46. package/src/tools/Chat/public.ts +16 -0
  47. package/src/tools/Chat/settings/README.md +87 -0
  48. package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
  49. package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
  50. package/src/tools/Chat/settings/index.ts +23 -0
  51. package/src/tools/Chat/settings/types.ts +108 -0
  52. package/src/tools/Chat/settings/useChatSettings.ts +168 -0
  53. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
  54. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
@@ -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>();
@@ -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';
@@ -61,6 +61,7 @@ export { DEFAULT_LABELS } from './types';
61
61
  export {
62
62
  STORAGE_KEYS,
63
63
  CSS_VARS,
64
+ Z_INDEX,
64
65
  DEFAULT_Z_INDEX,
65
66
  LIMITS,
66
67
  DEFAULT_SIDEBAR,
@@ -151,6 +152,21 @@ export {
151
152
  type UseChatAudioReturn,
152
153
  } from './core/audio';
153
154
 
155
+ // Settings — the single, centralized home for chat-owned persisted
156
+ // settings. Every chat feature reads/writes its slice through this hook.
157
+ export {
158
+ useChatSettings,
159
+ CHAT_SETTINGS_STORAGE_KEY,
160
+ DEFAULT_CHAT_SETTINGS,
161
+ type ChatSettings,
162
+ type ChatDockSettings,
163
+ type ChatAudioSettings,
164
+ type ChatSpeechSettings,
165
+ type ChatPageContextSettings,
166
+ type UseChatSettingsOptions,
167
+ type UseChatSettingsReturn,
168
+ } from './settings';
169
+
154
170
  // Notifier — title rotation + favicon badge + page-visibility + cross-tab
155
171
  export {
156
172
  createBrowserNotifier,
@@ -0,0 +1,87 @@
1
+ # Chat settings — `tools/Chat/settings/`
2
+
3
+ The single, centralized home for every **chat-owned persisted setting**.
4
+
5
+ ## Why this exists
6
+
7
+ Chat features used to each call `useLocalStorage` (or zustand `persist`)
8
+ with their own ad-hoc storage key. That made it impossible to reason
9
+ about, export, or reset "the chat's settings" as one thing, and
10
+ scattered the SSR / migration / coalescing concerns across the codebase.
11
+
12
+ This module gives chat **one typed object, one storage key, one hook**:
13
+
14
+ - `ChatSettings` — the typed shape of all chat-owned settings.
15
+ - `useChatSettings()` — the only hook a chat feature should use to read or
16
+ write a persisted preference. Built on the improved `useLocalStorage`
17
+ from `@djangocfg/ui-core`, which gives us:
18
+ - **Coalesced writes** — several features patching different slices in
19
+ the same render commit collapse into one `localStorage` write.
20
+ - **Cross-instance / cross-tab sync** — two `useChatSettings()`
21
+ consumers stay in lockstep, same tab and across tabs.
22
+ - **Safe partial updates** — a slice updater that changes nothing is a
23
+ no-op (no write, no re-render).
24
+
25
+ ## API
26
+
27
+ ```ts
28
+ const {
29
+ settings, // full typed ChatSettings object
30
+ patch, // grouped patch over whole slices
31
+ updateDock, // updateDock({ side: 'left' })
32
+ updateAudio,
33
+ updateSpeech,
34
+ updatePageContext,
35
+ setAudioMuted, // shortcut
36
+ setPageContextLinked,// shortcut
37
+ reset,
38
+ } = useChatSettings();
39
+ ```
40
+
41
+ Storage key: `djc.chat.settings`.
42
+
43
+ ## Migration status
44
+
45
+ ### Migrated
46
+
47
+ - **Page-context opt-in** — `lib/page-snapshot/react/provider.tsx` now
48
+ reads/writes `ChatSettings.pageContext.linked` via `useChatSettings`
49
+ instead of its own `useLocalStorage('djc.page-snapshot.linked')`.
50
+ Note: this changes the storage key, so an existing user's prior opt-in
51
+ is not carried over — they start from the default (`linked: false`).
52
+ That is acceptable: page-context is opt-in and a fresh default is safe.
53
+
54
+ ### To migrate (safe follow-up — deferred to keep this pass low-risk)
55
+
56
+ These are mature, working call sites. They each persist correctly today;
57
+ moving them into `ChatSettings` is a clear next step but was deliberately
58
+ deferred so a single PR does not churn the whole chat. Each migration
59
+ also changes a storage key (acceptable — fresh defaults).
60
+
61
+ - **Dock / layout prefs**
62
+ - `tools/Chat/hooks/useChatDockPrefs.ts` — key `chat.dock.prefs`,
63
+ shape `{ mode, side, sideWidth }` → `ChatSettings.dock`.
64
+ - `tools/Chat/hooks/useChatLayout.ts` — keys `djc-chat-mode`
65
+ (`STORAGE_KEYS.mode`) and `djc-chat-sidebar-width`
66
+ (`STORAGE_KEYS.sidebarWidth`) → `ChatSettings.dock.mode` /
67
+ `ChatSettings.dock.width`. Note `useChatLayout` stores `mode` and
68
+ width as two separate keys; consolidate into the `dock` slice.
69
+ - Constants live in `tools/Chat/constants.ts` (`STORAGE_KEYS`).
70
+
71
+ - **Audio prefs**
72
+ - `tools/Chat/hooks/useChatAudio.ts` delegates to ui-core's
73
+ `useNotificationSounds`, which persists under
74
+ `djangocfg-chat-audio:prefs`. Migrating means either teaching
75
+ `useNotificationSounds` to accept an external value/setter, or
76
+ syncing `ChatSettings.audio` ⇆ that hook. Non-trivial — leave as is.
77
+
78
+ - **Speech / language prefs**
79
+ - `tools/SpeechRecognition/store/prefsStore.ts` — a zustand `persist`
80
+ store under `djangocfg-stt:prefs`, shape `{ language, deviceId,
81
+ engineId, earcons }` → `ChatSettings.speech`. This is a different
82
+ persistence mechanism (zustand, not `useLocalStorage`); migrating it
83
+ means reconciling the store with `useChatSettings` and is the
84
+ highest-effort of the three. Leave as is.
85
+
86
+ When migrating any of the above: update the consuming components, delete
87
+ the old key from `STORAGE_KEYS` where applicable, and update this file.