@djangocfg/ui-tools 2.1.393 → 2.1.395

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,16 +1,24 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import type { ReactNode } from 'react';
5
5
 
6
6
  import { useHotkey } from '@djangocfg/ui-core/hooks';
7
7
 
8
+ import { ChatProvider, useChatContextOptional } from '../context';
9
+ import type { ChatAudioConfig } from '../core/audio/types';
10
+ import type {
11
+ ChatConfig,
12
+ ChatMessage,
13
+ ChatTransport,
14
+ } from '../types';
15
+
8
16
  import { ChatFAB, type ChatFABPosition, type ChatFABProps } from './ChatFAB';
9
17
  import { ChatDock, type ChatDockProps } from './ChatDock';
10
18
  import { ChatGreeting, type ChatGreetingProps } from './ChatGreeting';
11
- import { ChatHeaderAudioToggle } from './ChatHeaderAudioToggle';
12
19
  import { ChatUnreadPreview, type ChatUnreadPreviewProps } from './ChatUnreadPreview';
13
- import type { ChatMessage } from '../types';
20
+ import { HeaderSlotsRenderer } from './HeaderSlots';
21
+ import { resolveHeaderSlots, type ChatHeaderSlots } from './types';
14
22
 
15
23
  export interface ChatLauncherHotkey {
16
24
  /** Key (case-sensitive single char or named like 'Escape'). */
@@ -34,73 +42,73 @@ export interface ChatLauncherGreeting
34
42
  }
35
43
 
36
44
  export interface ChatLauncherProps {
37
- /** Dock contents typically a `<Chat>` instance. */
45
+ // ---- chat-provider wiring (mounts <ChatProvider> internally) -----------
46
+ /**
47
+ * Transport. Required unless the launcher is mounted inside an
48
+ * existing `<ChatProvider>` (in which case the ambient provider is
49
+ * reused and `transport` is ignored).
50
+ */
51
+ transport?: ChatTransport;
52
+ /** Optional chat config (labels, prefs, persona, etc.). */
53
+ config?: ChatConfig;
54
+ /** Pre-existing session to attach to. */
55
+ initialSessionId?: string;
56
+ /** Create a new backend session automatically when none is provided. */
57
+ autoCreateSession?: boolean;
58
+ /** Enable streaming. Defaults to transport's preference. */
59
+ streaming?: boolean;
60
+ /**
61
+ * Audio-trigger configuration (sounds map). The launcher owns the
62
+ * `useChatAudio()` hook internally; consumers no longer construct it
63
+ * themselves.
64
+ */
65
+ audio?: ChatAudioConfig;
66
+ /** Verbose dev logging via consola. */
67
+ debug?: boolean;
68
+ /** Rewrite outgoing content before transport. */
69
+ onBeforeSend?: (content: string) => string | Promise<string>;
70
+
71
+ // ---- visual chrome ------------------------------------------------------
72
+ /** Dock contents — typically a `<ChatRoot>` or custom chat shell. */
38
73
  children: ReactNode;
39
- /** FAB customization (icon, position, label, pulse, badge, tooltip, variant, size). */
74
+ /** FAB customization. */
40
75
  fab?: Omit<ChatFABProps, 'onClick'>;
41
- /** Dock customization (size, title, position, transition, mobileFullscreen). */
42
- dock?: Omit<ChatDockProps, 'open' | 'onClose' | 'children'>;
76
+ /** Dock customization. `headerActions` is computed from `headerSlots`. */
77
+ dock?: Omit<ChatDockProps, 'open' | 'onClose' | 'children' | 'headerActions'>;
78
+ /**
79
+ * Declarative header buttons rendered INSIDE the launcher's
80
+ * `<ChatProvider>`. See `ChatHeaderSlots` for the available knobs.
81
+ */
82
+ headerSlots?: ChatHeaderSlots;
43
83
  /**
44
- * Proactive greeting bubble shown next to the FAB before the user opens the chat.
45
- * Set to a string or full config object. Omit to disable.
84
+ * Proactive greeting bubble shown next to the FAB before the user
85
+ * opens the chat.
46
86
  */
47
87
  greeting?: string | ChatLauncherGreeting;
48
88
  /** Open/close via a keyboard shortcut. */
49
89
  hotkey?: ChatLauncherHotkey;
50
90
  /** Initial open state for uncontrolled mode. @default false */
51
91
  defaultOpen?: boolean;
52
- /** Controlled open state (pair with `onOpenChange`). */
92
+ /** Controlled open state. */
53
93
  open?: boolean;
54
94
  /** Controlled open state setter. */
55
95
  onOpenChange?: (open: boolean) => void;
56
- /**
57
- * Focus the composer textarea when the dock opens. Saves a click for
58
- * every "FAB → start typing" interaction. @default true
59
- */
96
+ /** Focus the composer when the dock opens. @default true */
60
97
  autoFocusComposerOnOpen?: boolean;
61
- /**
62
- * Close the dock on `Escape`. Mirrors standard popover / drawer UX.
63
- * Set to `false` to disable (e.g. if you want Escape to do something
64
- * else inside the chat). @default true
65
- */
98
+ /** Close the dock on Escape. @default true */
66
99
  closeOnEscape?: boolean;
67
100
  /**
68
- * Last inbound message (admin reply / system notice / agent push) the
69
- * user hasn't seen yet. Drives the `<ChatUnreadPreview>` bubble next
70
- * to the FAB and (by default) the FAB badge.
71
- *
72
- * Source it from `useChatUnread()` inside your `<ChatProvider>`.
101
+ * Last unread inbound message drives `<ChatUnreadPreview>` and the
102
+ * FAB badge.
73
103
  */
74
104
  unreadMessage?: ChatMessage | null;
75
- /**
76
- * Called when the user opens the chat via FAB/preview/hotkey or
77
- * dismisses the preview with ×. Wire to `useChatUnread().markRead`.
78
- */
105
+ /** Called when the chat is opened or the preview dismissed. */
79
106
  onMarkRead?: () => void;
80
- /**
81
- * Customize the unread bubble (`truncate`, `dismissLabel`, …).
82
- * `open`/`message`/`onClick`/`onDismiss`/`position`/`fabOffset` are
83
- * wired automatically.
84
- */
107
+ /** Customize the unread bubble. */
85
108
  unreadPreview?: Omit<
86
109
  ChatUnreadPreviewProps,
87
110
  'open' | 'message' | 'onClick' | 'onDismiss' | 'position' | 'fabOffset'
88
111
  >;
89
- /**
90
- * Auto-inject a mute / unmute button into the header. Pass the
91
- * `useChatAudio()` (or any compatible `{ muted, toggleMute }`)
92
- * instance — the launcher renders `<ChatHeaderAudioToggle>` in the
93
- * header's actions slot when audio is actually configured (not silent).
94
- *
95
- * Hosts that manage their own header can ignore this prop and render
96
- * `<ChatHeaderAudioToggle>` directly.
97
- */
98
- audio?: { muted: boolean; toggleMute: () => void; isSilent?: boolean } | null;
99
- /**
100
- * Suppress the auto-injected audio toggle even when `audio` is passed.
101
- * @default false
102
- */
103
- hideAudioToggle?: boolean;
104
112
  }
105
113
 
106
114
  function readDismissed(storageKey: string | null | undefined): boolean {
@@ -124,15 +132,31 @@ function writeDismissed(storageKey: string | null | undefined): void {
124
132
  }
125
133
 
126
134
  /**
127
- * Floating chat launcher = FAB + Dock + presence + optional greeting + hotkey.
135
+ * Floating chat launcher = `<ChatProvider>` + FAB + Dock + presence
136
+ * + optional greeting + hotkey.
128
137
  *
129
- * 99% of hosts use this directly. For non-FAB triggers (e.g. an inline
130
- * link in the page) compose `<ChatDock>` with your own button.
138
+ * The provider lives at this level so:
139
+ * - declarative `headerSlots` (e.g. `reset`) can read `sessionId` /
140
+ * call `clearMessages()` via `useChatContext()` while rendering in
141
+ * the dock header, which is a sibling of `children` (not a child).
142
+ * - descendant `<ChatRoot>` instances detect the ambient provider and
143
+ * skip wrapping in a second one.
131
144
  */
132
145
  export function ChatLauncher({
146
+ // provider wiring
147
+ transport,
148
+ config,
149
+ initialSessionId,
150
+ autoCreateSession,
151
+ streaming,
152
+ audio,
153
+ debug,
154
+ onBeforeSend,
155
+ // visual chrome
133
156
  children,
134
157
  fab,
135
158
  dock,
159
+ headerSlots,
136
160
  greeting,
137
161
  hotkey,
138
162
  defaultOpen = false,
@@ -143,8 +167,6 @@ export function ChatLauncher({
143
167
  unreadMessage,
144
168
  onMarkRead,
145
169
  unreadPreview,
146
- audio,
147
- hideAudioToggle = false,
148
170
  }: ChatLauncherProps) {
149
171
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
150
172
  const isControlled = controlledOpen !== undefined;
@@ -152,9 +174,6 @@ export function ChatLauncher({
152
174
  const dockContentRef = useRef<HTMLDivElement>(null);
153
175
 
154
176
  // Auto-focus the composer when the dock opens.
155
- // We probe the dock subtree for the first textarea/input after the
156
- // enter-transition settles. Keeps the hook self-contained — no need to
157
- // bridge into the ChatProvider context which lives inside `children`.
158
177
  useEffect(() => {
159
178
  if (!autoFocusComposerOnOpen || !open) return;
160
179
  const t = setTimeout(() => {
@@ -177,12 +196,6 @@ export function ChatLauncher({
177
196
  );
178
197
  const toggleOpen = useCallback(() => setOpen(!open), [open, setOpen]);
179
198
 
180
- // Two-step Escape (ChatGPT / Slack behaviour):
181
- // - When focus is inside a textarea / input / contenteditable, first Esc
182
- // just blurs — drafts survive accidental presses.
183
- // - When focus is elsewhere (the user already left the composer or never
184
- // focused it), Esc closes the dock.
185
- // Disabled when chat is shut so we don't intercept page-level Esc bindings.
186
199
  useHotkey(
187
200
  'escape',
188
201
  (e) => {
@@ -227,7 +240,6 @@ export function ChatLauncher({
227
240
  return () => window.removeEventListener('keydown', handler);
228
241
  }, [hotkey?.key, hotkey?.meta, hotkey?.shift, hotkey?.alt, open, setOpen, hotkey]);
229
242
 
230
- // Greeting visibility: respect dismissal, hideOnOpen, and the actual open state.
231
243
  const greetingOpen = !!greetingConfig
232
244
  && !dismissed
233
245
  && (greetingConfig.hideOnOpen === false || !open);
@@ -242,22 +254,14 @@ export function ChatLauncher({
242
254
 
243
255
  const handleGreetingClick = () => {
244
256
  setOpen(true);
245
- // Tap-to-open also clears the proactive bubble — pushing it again on
246
- // the same visit would feel spammy. Persisted dismissal honours the
247
- // storage key so it doesn't reappear after navigation either.
248
257
  setDismissed(true);
249
258
  writeDismissed(greetingConfig?.dismissStorageKey);
250
259
  };
251
260
 
252
- // Mark-as-read also fires when the chat opens through any path (FAB,
253
- // hotkey, controlled state) — symmetric with click-to-open via the
254
- // preview itself.
255
261
  useEffect(() => {
256
262
  if (open && unreadMessage) onMarkRead?.();
257
263
  }, [open, unreadMessage, onMarkRead]);
258
264
 
259
- // Unread preview replaces the greeting when there's a real inbound
260
- // message to surface — same anchor, more relevant content.
261
265
  const unreadOpen = !open && !!unreadMessage;
262
266
  const handleUnreadClick = () => {
263
267
  setOpen(true);
@@ -267,12 +271,40 @@ export function ChatLauncher({
267
271
  onMarkRead?.();
268
272
  };
269
273
 
270
- // Auto-derive a "1" badge from unread when the host didn't set one.
271
274
  const resolvedFab = unreadMessage && fab?.badge === undefined
272
275
  ? { ...fab, badge: 1 }
273
276
  : fab;
274
277
 
275
- return (
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.
281
+ const audioConfigured = useMemo<boolean>(() => {
282
+ if (!audio) return false;
283
+ if (audio.silenced) return false;
284
+ const sounds = audio.sounds;
285
+ // `useChatAudio` falls back to DEFAULT_CHAT_SOUNDS when `sounds` is
286
+ // undefined and `silenced` is false. Treat that as "configured" too.
287
+ if (sounds === undefined) return true;
288
+ return Object.values(sounds).some(
289
+ (v) => typeof v === 'string' && v.length > 0,
290
+ );
291
+ }, [audio]);
292
+
293
+ const resolvedSlots = useMemo(
294
+ () => resolveHeaderSlots(headerSlots, audioConfigured),
295
+ [headerSlots, audioConfigured],
296
+ );
297
+
298
+ const hasAnySlot =
299
+ resolvedSlots.audio ||
300
+ resolvedSlots.modeToggle !== null ||
301
+ resolvedSlots.languagePicker !== null ||
302
+ resolvedSlots.reset !== null ||
303
+ resolvedSlots.custom !== null;
304
+
305
+ const ambient = useChatContextOptional();
306
+
307
+ const body = (
276
308
  <>
277
309
  <ChatFAB {...resolvedFab} onClick={toggleOpen} />
278
310
  {unreadMessage ? (
@@ -302,14 +334,7 @@ export function ChatLauncher({
302
334
  open={open}
303
335
  onClose={() => setOpen(false)}
304
336
  headerActions={
305
- (audio && !audio.isSilent && !hideAudioToggle) || dock?.headerActions ? (
306
- <>
307
- {dock?.headerActions}
308
- {audio && !audio.isSilent && !hideAudioToggle ? (
309
- <ChatHeaderAudioToggle muted={audio.muted} onToggle={audio.toggleMute} />
310
- ) : null}
311
- </>
312
- ) : undefined
337
+ hasAnySlot ? <HeaderSlotsRenderer slots={resolvedSlots} /> : undefined
313
338
  }
314
339
  >
315
340
  <div ref={dockContentRef} className="flex h-full min-h-0 min-w-0 flex-col">
@@ -318,4 +343,33 @@ export function ChatLauncher({
318
343
  </ChatDock>
319
344
  </>
320
345
  );
346
+
347
+ if (ambient) {
348
+ // Already inside a ChatProvider — reuse it. Provider-level props
349
+ // (transport / config / audio / debug / onBeforeSend) are ignored
350
+ // because they belong to the upstream provider.
351
+ return body;
352
+ }
353
+
354
+ if (!transport) {
355
+ // No ambient provider and no transport — programmer error.
356
+ throw new Error(
357
+ '<ChatLauncher> requires `transport` when mounted outside a <ChatProvider>.',
358
+ );
359
+ }
360
+
361
+ return (
362
+ <ChatProvider
363
+ transport={transport}
364
+ config={config}
365
+ initialSessionId={initialSessionId}
366
+ autoCreateSession={autoCreateSession}
367
+ streaming={streaming}
368
+ audio={audio}
369
+ debug={debug}
370
+ onBeforeSend={onBeforeSend}
371
+ >
372
+ {body}
373
+ </ChatProvider>
374
+ );
321
375
  }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { Fragment } from 'react';
4
+
5
+ import { useChatContext } from '../context';
6
+ import { useChatDockPrefs } from '../hooks/useChatDockPrefs';
7
+
8
+ import { ChatHeaderAudioToggle } from './ChatHeaderAudioToggle';
9
+ import { ChatHeaderLanguageButton } from './ChatHeaderLanguageButton';
10
+ import { ChatHeaderModeToggle } from './ChatHeaderModeToggle';
11
+ import { ChatHeaderResetButton } from './ChatHeaderResetButton';
12
+ import type { ResolvedChatHeaderSlots } from './types';
13
+
14
+ export interface HeaderSlotsRendererProps {
15
+ slots: ResolvedChatHeaderSlots;
16
+ }
17
+
18
+ /**
19
+ * Renders the declarative `headerSlots` config inside the
20
+ * `<ChatProvider>` mounted by `<ChatLauncher>`.
21
+ *
22
+ * Order (left → right, before the close icon):
23
+ * custom · languagePicker · modeToggle · audio · reset
24
+ */
25
+ export function HeaderSlotsRenderer({ slots }: HeaderSlotsRendererProps) {
26
+ const ctx = useChatContext();
27
+ return (
28
+ <>
29
+ {slots.custom ? <Fragment>{slots.custom(ctx)}</Fragment> : null}
30
+ {slots.languagePicker ? (
31
+ <ChatHeaderLanguageButton
32
+ allowedTags={slots.languagePicker.allowedTags}
33
+ ariaLabel={slots.languagePicker.ariaLabel}
34
+ hideFallbackIcon={slots.languagePicker.hideFallbackIcon}
35
+ />
36
+ ) : null}
37
+ {slots.modeToggle ? <ModeToggleSlot slot={slots.modeToggle} /> : null}
38
+ {slots.audio && !ctx.audio.isSilent ? (
39
+ <ChatHeaderAudioToggle
40
+ muted={ctx.audio.muted}
41
+ onToggle={ctx.audio.toggleMute}
42
+ />
43
+ ) : null}
44
+ {slots.reset && ctx.sessionId ? <ResetSlot slot={slots.reset} /> : null}
45
+ </>
46
+ );
47
+ }
48
+
49
+ function ModeToggleSlot({
50
+ slot,
51
+ }: {
52
+ slot: NonNullable<ResolvedChatHeaderSlots['modeToggle']>;
53
+ }) {
54
+ const prefs = useChatDockPrefs({
55
+ storageKey: slot.persistAs,
56
+ defaults: slot.defaults,
57
+ });
58
+ return (
59
+ <ChatHeaderModeToggle
60
+ mode={prefs.mode}
61
+ onToggle={prefs.toggleMode}
62
+ forceVisible={slot.forceVisible ?? true}
63
+ expandLabel={slot.expandLabel}
64
+ collapseLabel={slot.collapseLabel}
65
+ />
66
+ );
67
+ }
68
+
69
+ function ResetSlot({
70
+ slot,
71
+ }: {
72
+ slot: NonNullable<ResolvedChatHeaderSlots['reset']>;
73
+ }) {
74
+ const ctx = useChatContext();
75
+ const handleSuccess = () => {
76
+ if (slot.onSuccess) {
77
+ slot.onSuccess(ctx);
78
+ return;
79
+ }
80
+ ctx.clearMessages();
81
+ };
82
+ return (
83
+ <ChatHeaderResetButton
84
+ onReset={slot.onReset}
85
+ onSuccess={handleSuccess}
86
+ onError={slot.onError}
87
+ confirm={slot.confirm}
88
+ confirmTitle={slot.confirmTitle}
89
+ confirmMessage={slot.confirmMessage}
90
+ ariaLabel={slot.ariaLabel}
91
+ />
92
+ );
93
+ }
@@ -38,6 +38,12 @@ export {
38
38
  type ChatLauncherHotkey,
39
39
  type ChatLauncherGreeting,
40
40
  } from './ChatLauncher';
41
+ export type {
42
+ ChatHeaderSlots,
43
+ ChatHeaderResetSlot,
44
+ ChatHeaderLanguageSlot,
45
+ ChatHeaderModeToggleSlot,
46
+ } from './types';
41
47
  export { ChatGreeting, type ChatGreetingProps } from './ChatGreeting';
42
48
  export {
43
49
  ChatUnreadPreview,
@@ -0,0 +1,132 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { ChatContextValue } from '../context';
4
+ import type { ChatDockPrefs } from '../hooks/useChatDockPrefs';
5
+
6
+ /**
7
+ * Declarative reset slot. The launcher renders the standard
8
+ * `<ChatHeaderResetButton>` wired to `onReset`, defaulting `onSuccess`
9
+ * to `ctx.clearMessages()`. The button hides itself until a `sessionId`
10
+ * exists on the chat context.
11
+ */
12
+ export interface ChatHeaderResetSlot {
13
+ /** Backend reset call. Resolves to `true` on success. */
14
+ onReset: () => Promise<boolean>;
15
+ /** Show a confirm dialog before calling `onReset`. @default true */
16
+ confirm?: boolean;
17
+ /** Confirm dialog title. */
18
+ confirmTitle?: string;
19
+ /** Confirm dialog message. */
20
+ confirmMessage?: string;
21
+ /** Override tooltip / aria label. */
22
+ ariaLabel?: string;
23
+ /**
24
+ * Called after a successful reset. Defaults to `ctx.clearMessages()`.
25
+ * Override to also re-fetch history, navigate, fire analytics, etc.
26
+ */
27
+ onSuccess?: (ctx: ChatContextValue) => void;
28
+ /** Called on failure (returned `false` or threw). */
29
+ onError?: (err?: unknown) => void;
30
+ }
31
+
32
+ /** Declarative language-picker slot. */
33
+ export interface ChatHeaderLanguageSlot {
34
+ /** Subset of BCP-47 tags to offer. */
35
+ allowedTags?: string[];
36
+ /** Override aria-label. */
37
+ ariaLabel?: string;
38
+ /** Hide the globe fallback icon when no flag resolves. */
39
+ hideFallbackIcon?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Declarative mode-toggle slot. The launcher owns `useChatDockPrefs`
44
+ * internally — set `persistAs` to a localStorage key for persistence
45
+ * (omit for ephemeral / session-only mode toggling).
46
+ */
47
+ export interface ChatHeaderModeToggleSlot {
48
+ /**
49
+ * localStorage key for `useChatDockPrefs`. When provided the toggle
50
+ * persists across reloads; omit to use an ephemeral in-memory toggle.
51
+ */
52
+ persistAs?: string;
53
+ /** Override the default `{ mode: 'popover', side: 'right', sideWidth: 420 }`. */
54
+ defaults?: Partial<ChatDockPrefs>;
55
+ /** Show the toggle even on viewports below `lg` (1024px). @default true */
56
+ forceVisible?: boolean;
57
+ /** Tooltip / aria for popover → side. */
58
+ expandLabel?: string;
59
+ /** Tooltip / aria for side → popover. */
60
+ collapseLabel?: string;
61
+ }
62
+
63
+ /**
64
+ * Header buttons rendered INSIDE the `<ChatProvider>` mounted by
65
+ * `<ChatLauncher>`. Each entry is rendered in the fixed order:
66
+ *
67
+ * custom · languagePicker · modeToggle · audio · reset
68
+ *
69
+ * (close icon is always last, owned by `<ChatHeader>`).
70
+ *
71
+ * Slots that accept `boolean | object`:
72
+ * - `true` → render with default config
73
+ * - `false` → hide explicitly
74
+ * - object → render with the given options
75
+ *
76
+ * Defaults:
77
+ * - `audio`: rendered automatically when `audio` prop is passed to
78
+ * `<ChatLauncher>` AND the resolved instance is not silent.
79
+ * - `modeToggle`, `languagePicker`: off unless opted in.
80
+ * - `reset`: off unless `onReset` is provided.
81
+ */
82
+ export interface ChatHeaderSlots {
83
+ /** Auto-mute toggle. Defaults to true when launcher `audio` is configured. */
84
+ audio?: boolean;
85
+ /** Popover ↔ side mode toggle. */
86
+ modeToggle?: boolean | ChatHeaderModeToggleSlot;
87
+ /** Speech-recognition language picker. */
88
+ languagePicker?: boolean | ChatHeaderLanguageSlot;
89
+ /** Reset-conversation button. */
90
+ reset?: ChatHeaderResetSlot;
91
+ /** Arbitrary extra buttons rendered first (left-most). */
92
+ custom?: (ctx: ChatContextValue) => ReactNode;
93
+ }
94
+
95
+ /**
96
+ * Resolved mode-toggle config used by the launcher to pick between
97
+ * the dock's mode/side props and the prefs slot.
98
+ */
99
+ export interface ResolvedChatHeaderSlots {
100
+ audio: boolean;
101
+ modeToggle: ChatHeaderModeToggleSlot | null;
102
+ languagePicker: ChatHeaderLanguageSlot | null;
103
+ reset: ChatHeaderResetSlot | null;
104
+ custom: ((ctx: ChatContextValue) => ReactNode) | null;
105
+ }
106
+
107
+ export function resolveHeaderSlots(
108
+ slots: ChatHeaderSlots | undefined,
109
+ audioConfigured: boolean,
110
+ ): ResolvedChatHeaderSlots {
111
+ const s = slots ?? {};
112
+ const audio = s.audio ?? audioConfigured;
113
+ const modeToggle: ChatHeaderModeToggleSlot | null =
114
+ s.modeToggle === true
115
+ ? {}
116
+ : s.modeToggle && typeof s.modeToggle === 'object'
117
+ ? s.modeToggle
118
+ : null;
119
+ const languagePicker: ChatHeaderLanguageSlot | null =
120
+ s.languagePicker === true
121
+ ? {}
122
+ : s.languagePicker && typeof s.languagePicker === 'object'
123
+ ? s.languagePicker
124
+ : null;
125
+ return {
126
+ audio,
127
+ modeToggle,
128
+ languagePicker,
129
+ reset: s.reset ?? null,
130
+ custom: s.custom ?? null,
131
+ };
132
+ }
@@ -136,9 +136,11 @@ export {
136
136
  DEFAULT_DOCK_PREFS,
137
137
  useFocusOnEmptyClick,
138
138
  useChatUnread,
139
+ useChatUnreadNotifier,
139
140
  useChatLightbox,
140
141
  type UseChatUnreadOptions,
141
142
  type UseChatUnreadReturn,
143
+ type UseChatUnreadNotifierOptions,
142
144
  type UseChatConfig,
143
145
  type UseChatReturn,
144
146
  type UseChatComposerOptions,
@@ -171,6 +173,24 @@ export {
171
173
  type UseChatAudioReturn,
172
174
  } from './core/audio';
173
175
 
176
+ // Notifier — title rotation + favicon badge + cross-tab decorator. Pure
177
+ // browser-API code, no React, no UI components.
178
+ export {
179
+ createBrowserNotifier,
180
+ createNoopNotifier,
181
+ createTitleRotator,
182
+ createFaviconBadge,
183
+ createCrossTabNotifier,
184
+ isPageHidden,
185
+ onVisibilityChange,
186
+ type ChatNotifier,
187
+ type BrowserNotifierOptions,
188
+ type TitleRotatorOptions,
189
+ type TitleMode,
190
+ type FaviconBadgeOptions,
191
+ type CrossTabNotifierOptions,
192
+ } from './notifier';
193
+
174
194
  // Tool-call payload dispatcher — pure
175
195
  export {
176
196
  dispatchToolPayload,
@@ -269,6 +289,10 @@ export type {
269
289
  ChatLauncherProps,
270
290
  ChatLauncherHotkey,
271
291
  ChatLauncherGreeting,
292
+ ChatHeaderSlots,
293
+ ChatHeaderResetSlot,
294
+ ChatHeaderLanguageSlot,
295
+ ChatHeaderModeToggleSlot,
272
296
  ChatFABProps,
273
297
  ChatFABPosition,
274
298
  ChatFABVariant,
@@ -0,0 +1,64 @@
1
+ import type { ChatMessage } from '../types';
2
+
3
+ import { createFaviconBadge, type FaviconBadgeOptions } from './faviconBadge';
4
+ import { createTitleRotator, type TitleRotatorOptions } from './titleRotator';
5
+ import type { ChatNotifier } from './types';
6
+
7
+ export interface BrowserNotifierOptions {
8
+ /** Title rotation config. Pass `false` to disable title mutation. */
9
+ title?: TitleRotatorOptions | false;
10
+ /** Favicon badge config. Pass `false` to disable favicon mutation. */
11
+ favicon?: FaviconBadgeOptions | false;
12
+ }
13
+
14
+ const NOOP: ChatNotifier = { setUnread() {}, clear() {} };
15
+
16
+ /**
17
+ * Facebook-style unread notifier: alternates `document.title` between
18
+ * the base title and an alert, plus paints a small badge over the
19
+ * favicon. Both surfaces are optional and individually toggleable.
20
+ *
21
+ * Returns a no-op in SSR or non-DOM environments — safe to construct
22
+ * unconditionally.
23
+ *
24
+ * The notifier itself is **stateless w.r.t. visibility** by design;
25
+ * the hook layer (`useChatUnreadNotifier`) decides when to call
26
+ * `setUnread` vs `clear` based on page focus. Keeping the policy in
27
+ * the hook lets hosts swap in their own notifier (Wails dock badge,
28
+ * cross-tab Zustand broadcaster) without duplicating the gating logic.
29
+ */
30
+ export function createBrowserNotifier(opts: BrowserNotifierOptions = {}): ChatNotifier {
31
+ if (typeof document === 'undefined') return NOOP;
32
+
33
+ const title = opts.title === false ? null : createTitleRotator(opts.title);
34
+ const favicon = opts.favicon === false ? null : createFaviconBadge(opts.favicon);
35
+
36
+ let active = false;
37
+
38
+ return {
39
+ setUnread(count: number, latest?: ChatMessage | null) {
40
+ if (count <= 0) {
41
+ this.clear();
42
+ return;
43
+ }
44
+ if (active) {
45
+ title?.update(count, latest);
46
+ favicon?.set(count);
47
+ } else {
48
+ active = true;
49
+ title?.start(count, latest);
50
+ favicon?.set(count);
51
+ }
52
+ },
53
+ clear() {
54
+ if (!active) return;
55
+ active = false;
56
+ title?.stop();
57
+ favicon?.clear();
58
+ },
59
+ };
60
+ }
61
+
62
+ export function createNoopNotifier(): ChatNotifier {
63
+ return NOOP;
64
+ }