@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.
- package/README.md +14 -6
- package/package.json +6 -6
- package/src/components/FloatingToolbar/FloatingToolbar.css +1 -1
- package/src/tools/Chat/README.md +130 -42
- package/src/tools/Chat/components/ChatRoot.tsx +26 -2
- package/src/tools/Chat/hooks/index.ts +4 -0
- package/src/tools/Chat/hooks/useChatUnreadNotifier.ts +134 -0
- package/src/tools/Chat/index.ts +23 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +135 -81
- package/src/tools/Chat/launcher/HeaderSlots.tsx +93 -0
- package/src/tools/Chat/launcher/index.ts +6 -0
- package/src/tools/Chat/launcher/types.ts +132 -0
- package/src/tools/Chat/lazy.tsx +24 -0
- package/src/tools/Chat/notifier/createBrowserNotifier.ts +64 -0
- package/src/tools/Chat/notifier/createCrossTabNotifier.ts +99 -0
- package/src/tools/Chat/notifier/faviconBadge.ts +280 -0
- package/src/tools/Chat/notifier/index.ts +20 -0
- package/src/tools/Chat/notifier/titleRotator.ts +119 -0
- package/src/tools/Chat/notifier/types.ts +38 -0
- package/src/tools/Chat/notifier/visibility.ts +47 -0
- package/src/tools/ImageViewer/components/ImageViewer.tsx +2 -2
- package/src/tools/MarkdownEditor/styles.css +7 -7
- package/src/tools/PrettyCode/lazy.tsx +6 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
74
|
+
/** FAB customization. */
|
|
40
75
|
fab?: Omit<ChatFABProps, 'onClick'>;
|
|
41
|
-
/** Dock customization
|
|
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
|
|
45
|
-
*
|
|
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
|
|
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
|
|
69
|
-
*
|
|
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
|
|
135
|
+
* Floating chat launcher = `<ChatProvider>` + FAB + Dock + presence
|
|
136
|
+
* + optional greeting + hotkey.
|
|
128
137
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tools/Chat/lazy.tsx
CHANGED
|
@@ -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
|
+
}
|