@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.
- package/README.md +14 -6
- package/package.json +6 -6
- package/src/components/FloatingToolbar/FloatingToolbar.css +1 -1
- package/src/tools/Chat/README.md +150 -43
- package/src/tools/Chat/components/ChatRoot.tsx +35 -2
- package/src/tools/Chat/components/MessageBubble.tsx +18 -0
- 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/Mermaid/hooks/useMermaidRenderer.ts +1 -1
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +1 -1
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +1 -1
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +1 -1
|
@@ -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
|
+
}
|
package/src/tools/Chat/index.ts
CHANGED
|
@@ -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
|
|
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,
|