@djangocfg/ui-tools 2.1.394 → 2.1.397

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.
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ import { useActiveTabStore } from '@djangocfg/ui-core/hooks';
6
+
7
+ import {
8
+ createBrowserNotifier,
9
+ createCrossTabNotifier,
10
+ isPageHidden,
11
+ onVisibilityChange,
12
+ type BrowserNotifierOptions,
13
+ type ChatNotifier,
14
+ } from '../notifier';
15
+
16
+ import { useChatUnread, type UseChatUnreadOptions } from './useChatUnread';
17
+
18
+ export interface UseChatUnreadNotifierOptions extends UseChatUnreadOptions {
19
+ /**
20
+ * Custom notifier. Pass a host-specific implementation (Wails dock
21
+ * badge etc.) to opt out of the built-in browser title/favicon
22
+ * mutation. If omitted, a `createBrowserNotifier` instance is used.
23
+ */
24
+ notifier?: ChatNotifier;
25
+ /**
26
+ * Options forwarded to the default browser notifier. Ignored when an
27
+ * explicit `notifier` is provided.
28
+ */
29
+ browser?: BrowserNotifierOptions;
30
+ /**
31
+ * Master switch. Default `true`. Set false to keep the unread
32
+ * tracking but skip all environment mutation.
33
+ */
34
+ enabled?: boolean;
35
+ /**
36
+ * Cross-tab coordination. When enabled (default), only the elected
37
+ * leader tab mutates `document.title` / favicon — other tabs stay
38
+ * silent. The unread count is broadcast so every tab's FAB badge UI
39
+ * still reflects reality.
40
+ *
41
+ * Pass `false` to disable; pass an options object to customise the
42
+ * BroadcastChannel name. Disable in single-tab hosts (Wails / Electron)
43
+ * where leadership is moot.
44
+ */
45
+ crossTab?: boolean | { channel?: string };
46
+ }
47
+
48
+ /**
49
+ * Glue between `useChatUnread` and a `ChatNotifier`.
50
+ *
51
+ * Inputs that drive the notifier:
52
+ * 1. `useChatUnread` — provider-state-derived `{ count, unread }`.
53
+ * 2. Page visibility — clear when visible; re-arm when hidden+count>0.
54
+ * 3. Tab leadership (when `crossTab` enabled) — only leader mutates
55
+ * title/favicon; followers receive count broadcasts so their
56
+ * in-tab badge UI stays in sync.
57
+ *
58
+ * Returns `useChatUnread`'s shape, with the `count` overridden by
59
+ * cross-tab broadcasts when this tab is a follower (so the FAB badge
60
+ * shows the same number across every tab).
61
+ */
62
+ export function useChatUnreadNotifier(opts: UseChatUnreadNotifierOptions = {}) {
63
+ const {
64
+ notifier: notifierProp,
65
+ browser,
66
+ enabled = true,
67
+ crossTab = true,
68
+ ...unreadOpts
69
+ } = opts;
70
+ const unread = useChatUnread(unreadOpts);
71
+
72
+ // Cross-tab count from peers (followers see this; leader publishes).
73
+ const [peerCount, setPeerCount] = useState<number | null>(null);
74
+
75
+ const crossTabChannel =
76
+ typeof crossTab === 'object' ? crossTab.channel : undefined;
77
+ const crossTabEnabled = crossTab !== false;
78
+
79
+ // Build the notifier. Inner = host-supplied OR built-in browser.
80
+ // Wrap with cross-tab decorator when enabled.
81
+ const notifier = useMemo<ChatNotifier>(() => {
82
+ const inner = notifierProp ?? createBrowserNotifier(browser);
83
+ if (!crossTabEnabled) return inner;
84
+ return createCrossTabNotifier({
85
+ inner,
86
+ isLeader: () => useActiveTabStore.getState().isLeader,
87
+ channel: crossTabChannel,
88
+ onPeerUpdate: (count) => setPeerCount(count),
89
+ });
90
+ }, [notifierProp, browser, crossTabEnabled, crossTabChannel]);
91
+
92
+ const lastSyncedCount = useRef(0);
93
+
94
+ // Visibility-driven sync. Single effect owns both the listener and
95
+ // the imperative calls to keep ordering deterministic.
96
+ useEffect(() => {
97
+ if (!enabled) {
98
+ notifier.clear();
99
+ return;
100
+ }
101
+
102
+ const sync = () => {
103
+ const hidden = isPageHidden();
104
+ if (hidden && unread.count > 0) {
105
+ notifier.setUnread(unread.count, unread.unread);
106
+ lastSyncedCount.current = unread.count;
107
+ } else {
108
+ notifier.clear();
109
+ lastSyncedCount.current = 0;
110
+ }
111
+ };
112
+
113
+ sync();
114
+ const unsub = onVisibilityChange(sync);
115
+ return () => {
116
+ unsub();
117
+ notifier.clear();
118
+ };
119
+ }, [enabled, notifier, unread.count, unread.unread]);
120
+
121
+ // Final cleanup — release any host-side resources.
122
+ useEffect(() => () => notifier.dispose?.(), [notifier]);
123
+
124
+ // Effective count: max of local (this tab's own unread tracking) and
125
+ // peer broadcast. The max handles the case where a peer hasn't sent
126
+ // a broadcast yet (peerCount === null) — we trust local.
127
+ const effectiveCount =
128
+ peerCount !== null ? Math.max(unread.count, peerCount) : unread.count;
129
+
130
+ return {
131
+ ...unread,
132
+ count: effectiveCount,
133
+ };
134
+ }
@@ -103,6 +103,10 @@ export {
103
103
  type ChatLauncherProps,
104
104
  type ChatLauncherHotkey,
105
105
  type ChatLauncherGreeting,
106
+ type ChatHeaderSlots,
107
+ type ChatHeaderResetSlot,
108
+ type ChatHeaderLanguageSlot,
109
+ type ChatHeaderModeToggleSlot,
106
110
  type ChatGreetingProps,
107
111
  type ChatUnreadPreviewProps,
108
112
  type ChatPresencePhase,
@@ -124,8 +128,10 @@ export {
124
128
  DEFAULT_DOCK_PREFS,
125
129
  useFocusOnEmptyClick,
126
130
  useChatUnread,
131
+ useChatUnreadNotifier,
127
132
  type UseChatUnreadOptions,
128
133
  type UseChatUnreadReturn,
134
+ type UseChatUnreadNotifierOptions,
129
135
  type UseChatConfig,
130
136
  type UseChatReturn,
131
137
  type UseChatComposerOptions,
@@ -146,6 +152,23 @@ export {
146
152
  type UseFocusOnEmptyClickOptions,
147
153
  } from './hooks';
148
154
 
155
+ // Notifier — title rotation + favicon badge + page-visibility + cross-tab
156
+ export {
157
+ createBrowserNotifier,
158
+ createNoopNotifier,
159
+ createTitleRotator,
160
+ createFaviconBadge,
161
+ createCrossTabNotifier,
162
+ isPageHidden,
163
+ onVisibilityChange,
164
+ type ChatNotifier,
165
+ type BrowserNotifierOptions,
166
+ type TitleRotatorOptions,
167
+ type TitleMode,
168
+ type FaviconBadgeOptions,
169
+ type CrossTabNotifierOptions,
170
+ } from './notifier';
171
+
149
172
  // Audio
150
173
  export type {
151
174
  ChatAudioEvent,
@@ -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,