@djangocfg/ui-tools 2.1.381 → 2.1.383
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 +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-U25MEYAL.mjs +4 -0
- package/dist/DictationField-U25MEYAL.mjs.map +1 -0
- package/dist/DictationField-XWR5VOID.cjs +13 -0
- package/dist/DictationField-XWR5VOID.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4PFW7MIJ.cjs +837 -0
- package/dist/chunk-4PFW7MIJ.cjs.map +1 -0
- package/dist/chunk-C2YN6WEO.mjs +833 -0
- package/dist/chunk-C2YN6WEO.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1668 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1215 -107
- package/dist/index.d.ts +1215 -107
- package/dist/index.mjs +1555 -50
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -15
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +84 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +0 -771
- package/src/stories/index.ts +0 -33
- package/src/tools/AudioPlayer/AudioPlayer.story.tsx +0 -481
- package/src/tools/Chat/Chat.story.tsx +0 -1457
- package/src/tools/CodeEditor/CodeEditor.story.tsx +0 -202
- package/src/tools/CronScheduler/CronScheduler.story.tsx +0 -300
- package/src/tools/Gallery/Gallery.story.tsx +0 -237
- package/src/tools/ImageViewer/ImageViewer.story.tsx +0 -85
- package/src/tools/JsonForm/JsonForm.story.tsx +0 -350
- package/src/tools/JsonTree/JsonTree.story.tsx +0 -141
- package/src/tools/LottiePlayer/LottiePlayer.story.tsx +0 -95
- package/src/tools/Map/Map.story.tsx +0 -458
- package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +0 -225
- package/src/tools/Mermaid/Mermaid.story.tsx +0 -251
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +0 -230
- package/src/tools/PrettyCode/PrettyCode.story.tsx +0 -304
- package/src/tools/Tour/Tour.story.tsx +0 -279
- package/src/tools/Tree/Tree.story.tsx +0 -620
- package/src/tools/Uploader/Uploader.story.tsx +0 -415
- package/src/tools/VideoPlayer/VideoPlayer.story.tsx +0 -87
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
|
|
8
|
+
import { ChatFAB, type ChatFABPosition, type ChatFABProps } from './ChatFAB';
|
|
9
|
+
import { ChatDock, type ChatDockProps } from './ChatDock';
|
|
10
|
+
import { ChatGreeting, type ChatGreetingProps } from './ChatGreeting';
|
|
11
|
+
import { ChatHeaderAudioToggle } from './ChatHeaderAudioToggle';
|
|
12
|
+
import { ChatUnreadPreview, type ChatUnreadPreviewProps } from './ChatUnreadPreview';
|
|
13
|
+
import type { ChatMessage } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface ChatLauncherHotkey {
|
|
16
|
+
/** Key (case-sensitive single char or named like 'Escape'). */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Require Cmd (mac) or Ctrl (other). */
|
|
19
|
+
meta?: boolean;
|
|
20
|
+
/** Require Shift. */
|
|
21
|
+
shift?: boolean;
|
|
22
|
+
/** Require Alt. */
|
|
23
|
+
alt?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChatLauncherGreeting
|
|
27
|
+
extends Omit<ChatGreetingProps, 'open' | 'onClick' | 'onDismiss' | 'position' | 'fabOffset' | 'children'> {
|
|
28
|
+
/** Greeting body — string for the default style, or any ReactNode. */
|
|
29
|
+
content: ReactNode;
|
|
30
|
+
/** Persistence key for "user dismissed this greeting" in `localStorage`. Pass `null` to disable persistence. @default null */
|
|
31
|
+
dismissStorageKey?: string | null;
|
|
32
|
+
/** Hide the greeting once the user opens the chat. @default true */
|
|
33
|
+
hideOnOpen?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChatLauncherProps {
|
|
37
|
+
/** Dock contents — typically a `<Chat>` instance. */
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
/** FAB customization (icon, position, label, pulse, badge, tooltip, variant, size). */
|
|
40
|
+
fab?: Omit<ChatFABProps, 'onClick'>;
|
|
41
|
+
/** Dock customization (size, title, position, transition, mobileFullscreen). */
|
|
42
|
+
dock?: Omit<ChatDockProps, 'open' | 'onClose' | 'children'>;
|
|
43
|
+
/**
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
greeting?: string | ChatLauncherGreeting;
|
|
48
|
+
/** Open/close via a keyboard shortcut. */
|
|
49
|
+
hotkey?: ChatLauncherHotkey;
|
|
50
|
+
/** Initial open state for uncontrolled mode. @default false */
|
|
51
|
+
defaultOpen?: boolean;
|
|
52
|
+
/** Controlled open state (pair with `onOpenChange`). */
|
|
53
|
+
open?: boolean;
|
|
54
|
+
/** Controlled open state setter. */
|
|
55
|
+
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
|
+
*/
|
|
60
|
+
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
|
+
*/
|
|
66
|
+
closeOnEscape?: boolean;
|
|
67
|
+
/**
|
|
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>`.
|
|
73
|
+
*/
|
|
74
|
+
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
|
+
*/
|
|
79
|
+
onMarkRead?: () => void;
|
|
80
|
+
/**
|
|
81
|
+
* Customize the unread bubble (`truncate`, `dismissLabel`, …).
|
|
82
|
+
* `open`/`message`/`onClick`/`onDismiss`/`position`/`fabOffset` are
|
|
83
|
+
* wired automatically.
|
|
84
|
+
*/
|
|
85
|
+
unreadPreview?: Omit<
|
|
86
|
+
ChatUnreadPreviewProps,
|
|
87
|
+
'open' | 'message' | 'onClick' | 'onDismiss' | 'position' | 'fabOffset'
|
|
88
|
+
>;
|
|
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
|
+
}
|
|
105
|
+
|
|
106
|
+
function readDismissed(storageKey: string | null | undefined): boolean {
|
|
107
|
+
if (!storageKey) return false;
|
|
108
|
+
if (typeof window === 'undefined') return false;
|
|
109
|
+
try {
|
|
110
|
+
return window.localStorage.getItem(storageKey) === '1';
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeDismissed(storageKey: string | null | undefined): void {
|
|
117
|
+
if (!storageKey) return;
|
|
118
|
+
if (typeof window === 'undefined') return;
|
|
119
|
+
try {
|
|
120
|
+
window.localStorage.setItem(storageKey, '1');
|
|
121
|
+
} catch {
|
|
122
|
+
// private mode / storage full — silently ignore
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Floating chat launcher = FAB + Dock + presence + optional greeting + hotkey.
|
|
128
|
+
*
|
|
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.
|
|
131
|
+
*/
|
|
132
|
+
export function ChatLauncher({
|
|
133
|
+
children,
|
|
134
|
+
fab,
|
|
135
|
+
dock,
|
|
136
|
+
greeting,
|
|
137
|
+
hotkey,
|
|
138
|
+
defaultOpen = false,
|
|
139
|
+
open: controlledOpen,
|
|
140
|
+
onOpenChange,
|
|
141
|
+
autoFocusComposerOnOpen = true,
|
|
142
|
+
closeOnEscape = true,
|
|
143
|
+
unreadMessage,
|
|
144
|
+
onMarkRead,
|
|
145
|
+
unreadPreview,
|
|
146
|
+
audio,
|
|
147
|
+
hideAudioToggle = false,
|
|
148
|
+
}: ChatLauncherProps) {
|
|
149
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
150
|
+
const isControlled = controlledOpen !== undefined;
|
|
151
|
+
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
|
152
|
+
const dockContentRef = useRef<HTMLDivElement>(null);
|
|
153
|
+
|
|
154
|
+
// 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
|
+
useEffect(() => {
|
|
159
|
+
if (!autoFocusComposerOnOpen || !open) return;
|
|
160
|
+
const t = setTimeout(() => {
|
|
161
|
+
const root = dockContentRef.current;
|
|
162
|
+
if (!root) return;
|
|
163
|
+
const target = root.querySelector<HTMLElement>(
|
|
164
|
+
'textarea:not([disabled]):not([readonly]), input[type="text"]:not([disabled]):not([readonly])',
|
|
165
|
+
);
|
|
166
|
+
target?.focus();
|
|
167
|
+
}, 120);
|
|
168
|
+
return () => clearTimeout(t);
|
|
169
|
+
}, [open, autoFocusComposerOnOpen]);
|
|
170
|
+
|
|
171
|
+
const setOpen = useCallback(
|
|
172
|
+
(next: boolean) => {
|
|
173
|
+
if (!isControlled) setUncontrolledOpen(next);
|
|
174
|
+
onOpenChange?.(next);
|
|
175
|
+
},
|
|
176
|
+
[isControlled, onOpenChange],
|
|
177
|
+
);
|
|
178
|
+
const toggleOpen = useCallback(() => setOpen(!open), [open, setOpen]);
|
|
179
|
+
|
|
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
|
+
useHotkey(
|
|
187
|
+
'escape',
|
|
188
|
+
(e) => {
|
|
189
|
+
const target = (e?.target as HTMLElement | null) ?? null;
|
|
190
|
+
const inEditable =
|
|
191
|
+
!!target &&
|
|
192
|
+
(target.matches?.('input, textarea, [contenteditable="true"]') ?? false);
|
|
193
|
+
if (inEditable) {
|
|
194
|
+
target.blur();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setOpen(false);
|
|
198
|
+
},
|
|
199
|
+
{ enabled: closeOnEscape && open },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Normalize greeting prop.
|
|
203
|
+
const greetingConfig: ChatLauncherGreeting | null =
|
|
204
|
+
greeting === undefined
|
|
205
|
+
? null
|
|
206
|
+
: typeof greeting === 'string'
|
|
207
|
+
? { content: greeting }
|
|
208
|
+
: greeting;
|
|
209
|
+
|
|
210
|
+
const [dismissed, setDismissed] = useState(() =>
|
|
211
|
+
readDismissed(greetingConfig?.dismissStorageKey),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Hotkey.
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (!hotkey) return;
|
|
217
|
+
const handler = (e: KeyboardEvent) => {
|
|
218
|
+
const metaOk = hotkey.meta ? e.metaKey || e.ctrlKey : !e.metaKey && !e.ctrlKey;
|
|
219
|
+
const shiftOk = hotkey.shift ? e.shiftKey : !e.shiftKey;
|
|
220
|
+
const altOk = hotkey.alt ? e.altKey : !e.altKey;
|
|
221
|
+
if (!metaOk || !shiftOk || !altOk) return;
|
|
222
|
+
if (e.key !== hotkey.key) return;
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
setOpen(!open);
|
|
225
|
+
};
|
|
226
|
+
window.addEventListener('keydown', handler);
|
|
227
|
+
return () => window.removeEventListener('keydown', handler);
|
|
228
|
+
}, [hotkey?.key, hotkey?.meta, hotkey?.shift, hotkey?.alt, open, setOpen, hotkey]);
|
|
229
|
+
|
|
230
|
+
// Greeting visibility: respect dismissal, hideOnOpen, and the actual open state.
|
|
231
|
+
const greetingOpen = !!greetingConfig
|
|
232
|
+
&& !dismissed
|
|
233
|
+
&& (greetingConfig.hideOnOpen === false || !open);
|
|
234
|
+
|
|
235
|
+
const fabPosition: ChatFABPosition = fab?.position ?? 'bottom-right';
|
|
236
|
+
const fabOffset = fab?.offset ?? 24;
|
|
237
|
+
|
|
238
|
+
const handleGreetingDismiss = () => {
|
|
239
|
+
setDismissed(true);
|
|
240
|
+
writeDismissed(greetingConfig?.dismissStorageKey);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const handleGreetingClick = () => {
|
|
244
|
+
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
|
+
setDismissed(true);
|
|
249
|
+
writeDismissed(greetingConfig?.dismissStorageKey);
|
|
250
|
+
};
|
|
251
|
+
|
|
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
|
+
useEffect(() => {
|
|
256
|
+
if (open && unreadMessage) onMarkRead?.();
|
|
257
|
+
}, [open, unreadMessage, onMarkRead]);
|
|
258
|
+
|
|
259
|
+
// Unread preview replaces the greeting when there's a real inbound
|
|
260
|
+
// message to surface — same anchor, more relevant content.
|
|
261
|
+
const unreadOpen = !open && !!unreadMessage;
|
|
262
|
+
const handleUnreadClick = () => {
|
|
263
|
+
setOpen(true);
|
|
264
|
+
onMarkRead?.();
|
|
265
|
+
};
|
|
266
|
+
const handleUnreadDismiss = () => {
|
|
267
|
+
onMarkRead?.();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Auto-derive a "1" badge from unread when the host didn't set one.
|
|
271
|
+
const resolvedFab = unreadMessage && fab?.badge === undefined
|
|
272
|
+
? { ...fab, badge: 1 }
|
|
273
|
+
: fab;
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<>
|
|
277
|
+
<ChatFAB {...resolvedFab} onClick={toggleOpen} />
|
|
278
|
+
{unreadMessage ? (
|
|
279
|
+
<ChatUnreadPreview
|
|
280
|
+
{...unreadPreview}
|
|
281
|
+
open={unreadOpen}
|
|
282
|
+
message={unreadMessage}
|
|
283
|
+
onClick={handleUnreadClick}
|
|
284
|
+
onDismiss={handleUnreadDismiss}
|
|
285
|
+
position={fabPosition}
|
|
286
|
+
fabOffset={fabOffset}
|
|
287
|
+
/>
|
|
288
|
+
) : greetingConfig ? (
|
|
289
|
+
<ChatGreeting
|
|
290
|
+
{...greetingConfig}
|
|
291
|
+
open={greetingOpen}
|
|
292
|
+
onClick={handleGreetingClick}
|
|
293
|
+
onDismiss={handleGreetingDismiss}
|
|
294
|
+
position={fabPosition}
|
|
295
|
+
fabOffset={fabOffset}
|
|
296
|
+
>
|
|
297
|
+
{greetingConfig.content}
|
|
298
|
+
</ChatGreeting>
|
|
299
|
+
) : null}
|
|
300
|
+
<ChatDock
|
|
301
|
+
{...dock}
|
|
302
|
+
open={open}
|
|
303
|
+
onClose={() => setOpen(false)}
|
|
304
|
+
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
|
|
313
|
+
}
|
|
314
|
+
>
|
|
315
|
+
<div ref={dockContentRef} className="flex h-full min-h-0 min-w-0 flex-col">
|
|
316
|
+
{children}
|
|
317
|
+
</div>
|
|
318
|
+
</ChatDock>
|
|
319
|
+
</>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import type { ChatMessage, ChatPersona } from '../types';
|
|
10
|
+
import type { ChatFABPosition } from './ChatFAB';
|
|
11
|
+
import { useChatPresence } from './useChatPresence';
|
|
12
|
+
|
|
13
|
+
export interface ChatUnreadPreviewProps {
|
|
14
|
+
/** Controlled — usually `!dockOpen && !!message`. */
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** Inbound message to preview. `null` hides the bubble. */
|
|
17
|
+
message: ChatMessage | null;
|
|
18
|
+
/** Tap → open chat + mark read. */
|
|
19
|
+
onClick?: () => void;
|
|
20
|
+
/** × → mark read without opening. */
|
|
21
|
+
onDismiss?: () => void;
|
|
22
|
+
/** Anchor corner — match the FAB so the bubble sits above it. @default 'bottom-right' */
|
|
23
|
+
position?: ChatFABPosition;
|
|
24
|
+
/** Horizontal offset from screen edge, matches the FAB. @default 24 */
|
|
25
|
+
fabOffset?: number;
|
|
26
|
+
/** Vertical clearance above/below the FAB. @default 96 */
|
|
27
|
+
fabClearance?: number;
|
|
28
|
+
/** Lines of body text before ellipsis. @default 2 */
|
|
29
|
+
truncate?: number;
|
|
30
|
+
/** z-index. @default 9998 */
|
|
31
|
+
zIndex?: number;
|
|
32
|
+
/** Render in-place (stories / previews). @default false */
|
|
33
|
+
inline?: boolean;
|
|
34
|
+
/** Override classes on the bubble. */
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Override styles on the bubble. */
|
|
37
|
+
style?: CSSProperties;
|
|
38
|
+
/** ARIA label for the dismiss button. @default 'Mark as read' */
|
|
39
|
+
dismissLabel?: string;
|
|
40
|
+
/** Override the avatar — defaults to derived from `message.sender`. */
|
|
41
|
+
avatar?: ReactNode;
|
|
42
|
+
/** Override the sender label — defaults to `message.sender?.name`. */
|
|
43
|
+
senderName?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TIME_FORMAT = new Intl.DateTimeFormat(undefined, {
|
|
47
|
+
hour: '2-digit',
|
|
48
|
+
minute: '2-digit',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function anchorStyle(
|
|
52
|
+
position: ChatFABPosition,
|
|
53
|
+
fabOffset: number,
|
|
54
|
+
fabClearance: number,
|
|
55
|
+
): CSSProperties {
|
|
56
|
+
const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left'];
|
|
57
|
+
return { [vert]: fabClearance, [horiz]: fabOffset } as CSSProperties;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function originClass(position: ChatFABPosition): string {
|
|
61
|
+
if (position === 'bottom-right') return 'origin-bottom-right';
|
|
62
|
+
if (position === 'bottom-left') return 'origin-bottom-left';
|
|
63
|
+
if (position === 'top-right') return 'origin-top-right';
|
|
64
|
+
return 'origin-top-left';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deriveAvatar(persona?: ChatPersona, name?: string): ReactNode {
|
|
68
|
+
const initials =
|
|
69
|
+
persona?.initials ??
|
|
70
|
+
(name ?? persona?.name ?? '?')
|
|
71
|
+
.split(/\s+/)
|
|
72
|
+
.map((p) => p[0])
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.slice(0, 2)
|
|
75
|
+
.join('')
|
|
76
|
+
.toUpperCase();
|
|
77
|
+
return (
|
|
78
|
+
<Avatar className="h-9 w-9">
|
|
79
|
+
{persona?.avatarUrl ? <AvatarImage src={persona.avatarUrl} /> : null}
|
|
80
|
+
<AvatarFallback>{initials || '?'}</AvatarFallback>
|
|
81
|
+
</Avatar>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Push-notification bubble next to the chat FAB.
|
|
87
|
+
*
|
|
88
|
+
* Shows the last inbound message while the chat is closed —
|
|
89
|
+
* Intercom / LiveChat-style. Tap → open chat (host wires `onClick`).
|
|
90
|
+
* Dismiss × → keep chat closed but stop nagging.
|
|
91
|
+
*
|
|
92
|
+
* Pair with `useChatUnread()` (inside `<ChatProvider>`) for state.
|
|
93
|
+
*/
|
|
94
|
+
export function ChatUnreadPreview({
|
|
95
|
+
open,
|
|
96
|
+
message,
|
|
97
|
+
onClick,
|
|
98
|
+
onDismiss,
|
|
99
|
+
position = 'bottom-right',
|
|
100
|
+
fabOffset = 24,
|
|
101
|
+
fabClearance = 96,
|
|
102
|
+
truncate = 2,
|
|
103
|
+
zIndex = 9998,
|
|
104
|
+
inline = false,
|
|
105
|
+
className,
|
|
106
|
+
style,
|
|
107
|
+
dismissLabel = 'Mark as read',
|
|
108
|
+
avatar,
|
|
109
|
+
senderName,
|
|
110
|
+
}: ChatUnreadPreviewProps) {
|
|
111
|
+
const shouldShow = open && !!message;
|
|
112
|
+
const phase = useChatPresence(shouldShow, 200);
|
|
113
|
+
if (phase === 'hidden' || !message) return null;
|
|
114
|
+
|
|
115
|
+
const animating = phase === 'entering' || phase === 'leaving';
|
|
116
|
+
const clickable = !!onClick;
|
|
117
|
+
const displayName = senderName ?? message.sender?.name ?? 'New message';
|
|
118
|
+
const stamp = TIME_FORMAT.format(new Date(message.createdAt));
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
role={clickable ? 'button' : 'status'}
|
|
123
|
+
aria-live="polite"
|
|
124
|
+
tabIndex={clickable ? 0 : -1}
|
|
125
|
+
onClick={clickable ? onClick : undefined}
|
|
126
|
+
onKeyDown={
|
|
127
|
+
clickable
|
|
128
|
+
? (e) => {
|
|
129
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
onClick?.();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
: undefined
|
|
135
|
+
}
|
|
136
|
+
className={cn(
|
|
137
|
+
inline ? 'relative inline-flex' : 'fixed',
|
|
138
|
+
'flex items-start gap-2.5 max-w-[300px]',
|
|
139
|
+
'rounded-2xl border border-border bg-popover text-popover-foreground',
|
|
140
|
+
'px-3.5 py-2.5 shadow-2xl transition-all duration-200 ease-out',
|
|
141
|
+
clickable &&
|
|
142
|
+
'cursor-pointer hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
143
|
+
originClass(position),
|
|
144
|
+
animating ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0',
|
|
145
|
+
className,
|
|
146
|
+
)}
|
|
147
|
+
style={{
|
|
148
|
+
...(inline ? {} : anchorStyle(position, fabOffset, fabClearance)),
|
|
149
|
+
...(inline ? {} : { zIndex }),
|
|
150
|
+
pointerEvents: phase === 'visible' ? 'auto' : 'none',
|
|
151
|
+
...style,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<div className="mt-0.5 shrink-0">
|
|
155
|
+
{avatar ?? deriveAvatar(message.sender, displayName)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="min-w-0 flex-1 text-sm leading-snug">
|
|
159
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
160
|
+
<div className="truncate text-[12px] font-semibold text-foreground">
|
|
161
|
+
{displayName}
|
|
162
|
+
</div>
|
|
163
|
+
<div className="shrink-0 text-[10px] text-muted-foreground">{stamp}</div>
|
|
164
|
+
</div>
|
|
165
|
+
<div
|
|
166
|
+
className="text-foreground/90 mt-0.5 break-words"
|
|
167
|
+
style={{
|
|
168
|
+
display: '-webkit-box',
|
|
169
|
+
WebkitLineClamp: truncate,
|
|
170
|
+
WebkitBoxOrient: 'vertical',
|
|
171
|
+
overflow: 'hidden',
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{message.content}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{onDismiss ? (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
aria-label={dismissLabel}
|
|
182
|
+
onClick={(e) => {
|
|
183
|
+
e.stopPropagation();
|
|
184
|
+
onDismiss();
|
|
185
|
+
}}
|
|
186
|
+
className={cn(
|
|
187
|
+
'-mr-1 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
|
|
188
|
+
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
|
189
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<X className="h-3.5 w-3.5" />
|
|
193
|
+
</button>
|
|
194
|
+
) : null}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ChatFAB,
|
|
3
|
+
type ChatFABProps,
|
|
4
|
+
type ChatFABPosition,
|
|
5
|
+
type ChatFABVariant,
|
|
6
|
+
type ChatFABSize,
|
|
7
|
+
} from './ChatFAB';
|
|
8
|
+
export {
|
|
9
|
+
ChatDock,
|
|
10
|
+
type ChatDockProps,
|
|
11
|
+
type ChatDockMode,
|
|
12
|
+
type ChatDockSide,
|
|
13
|
+
} from './ChatDock';
|
|
14
|
+
export { ChatHeader, type ChatHeaderProps } from './ChatHeader';
|
|
15
|
+
export {
|
|
16
|
+
ChatHeaderActionButton,
|
|
17
|
+
type ChatHeaderActionButtonProps,
|
|
18
|
+
} from './ChatHeaderActionButton';
|
|
19
|
+
export {
|
|
20
|
+
ChatHeaderModeToggle,
|
|
21
|
+
type ChatHeaderModeToggleProps,
|
|
22
|
+
} from './ChatHeaderModeToggle';
|
|
23
|
+
export {
|
|
24
|
+
ChatHeaderAudioToggle,
|
|
25
|
+
type ChatHeaderAudioToggleProps,
|
|
26
|
+
} from './ChatHeaderAudioToggle';
|
|
27
|
+
export {
|
|
28
|
+
ChatHeaderResetButton,
|
|
29
|
+
type ChatHeaderResetButtonProps,
|
|
30
|
+
} from './ChatHeaderResetButton';
|
|
31
|
+
export {
|
|
32
|
+
ChatHeaderLanguageButton,
|
|
33
|
+
type ChatHeaderLanguageButtonProps,
|
|
34
|
+
} from './ChatHeaderLanguageButton';
|
|
35
|
+
export {
|
|
36
|
+
ChatLauncher,
|
|
37
|
+
type ChatLauncherProps,
|
|
38
|
+
type ChatLauncherHotkey,
|
|
39
|
+
type ChatLauncherGreeting,
|
|
40
|
+
} from './ChatLauncher';
|
|
41
|
+
export { ChatGreeting, type ChatGreetingProps } from './ChatGreeting';
|
|
42
|
+
export {
|
|
43
|
+
ChatUnreadPreview,
|
|
44
|
+
type ChatUnreadPreviewProps,
|
|
45
|
+
} from './ChatUnreadPreview';
|
|
46
|
+
export { useChatPresence, type ChatPresencePhase } from './useChatPresence';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type ChatPresencePhase = 'hidden' | 'entering' | 'visible' | 'leaving';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Presence state machine for floating popovers.
|
|
9
|
+
*
|
|
10
|
+
* Drives a four-phase lifecycle so enter/leave CSS transitions actually fire:
|
|
11
|
+
*
|
|
12
|
+
* hidden → entering (mount, transition class starts) → visible
|
|
13
|
+
* visible → leaving (transition runs) → hidden (unmount)
|
|
14
|
+
*
|
|
15
|
+
* Mounting in `entering` and ticking to `visible` on the next paint is what
|
|
16
|
+
* lets transition classes animate. Without it the element appears already
|
|
17
|
+
* at its final state and CSS transitions never observe a change.
|
|
18
|
+
*
|
|
19
|
+
* @param open - controlled open state
|
|
20
|
+
* @param exitDurationMs - how long the leave transition runs; should match CSS
|
|
21
|
+
*/
|
|
22
|
+
export function useChatPresence(open: boolean, exitDurationMs = 200): ChatPresencePhase {
|
|
23
|
+
const [phase, setPhase] = useState<ChatPresencePhase>('hidden');
|
|
24
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
28
|
+
|
|
29
|
+
if (open) {
|
|
30
|
+
setPhase('entering');
|
|
31
|
+
// One paint later: switch to 'visible' so the CSS transition runs.
|
|
32
|
+
timerRef.current = setTimeout(() => setPhase('visible'), 16);
|
|
33
|
+
} else {
|
|
34
|
+
setPhase('leaving');
|
|
35
|
+
timerRef.current = setTimeout(() => setPhase('hidden'), exitDurationMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
40
|
+
};
|
|
41
|
+
}, [open, exitDurationMs]);
|
|
42
|
+
|
|
43
|
+
return phase;
|
|
44
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat color tokens — single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Every role-conditional class for chat surfaces lives here.
|
|
5
|
+
* Components consume via `useChatBubbleStyles` / `useChatRoleStyles`,
|
|
6
|
+
* never via raw Tailwind literals.
|
|
7
|
+
*
|
|
8
|
+
* Why centralize:
|
|
9
|
+
* 1. One file to edit when the design system changes (e.g. light-theme
|
|
10
|
+
* contrast tweaks, palette swap).
|
|
11
|
+
* 2. Eliminates the "first-token-on-bg-primary-was-text-white" class
|
|
12
|
+
* of bugs where each call site picks its own foreground.
|
|
13
|
+
* 3. Lets us snapshot-test color decisions later.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Bubble surface classes (background + text), keyed by message state. */
|
|
17
|
+
export const BUBBLE_SURFACE = {
|
|
18
|
+
/** User-authored bubble — solid brand color. */
|
|
19
|
+
user: 'bg-primary text-primary-foreground rounded-tr-md',
|
|
20
|
+
/** Assistant bubble in normal state — neutral muted surface. */
|
|
21
|
+
assistant: 'bg-muted text-foreground rounded-tl-md',
|
|
22
|
+
/** Assistant bubble when the turn failed — destructive tint. */
|
|
23
|
+
error: 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Anchor (link) classes for markdown content rendered inside a bubble.
|
|
28
|
+
*
|
|
29
|
+
* On `bg-primary` the link MUST stay legible against the cyan/brand fill —
|
|
30
|
+
* `text-primary-foreground` matches the bubble's foreground token, so
|
|
31
|
+
* contrast tracks the design system automatically.
|
|
32
|
+
*
|
|
33
|
+
* On the neutral assistant bubble we keep the brand-primary color so links
|
|
34
|
+
* still pop without competing with the body text.
|
|
35
|
+
*/
|
|
36
|
+
export const ANCHOR = {
|
|
37
|
+
user:
|
|
38
|
+
'text-primary-foreground underline decoration-primary-foreground/60 underline-offset-2 ' +
|
|
39
|
+
'hover:decoration-primary-foreground transition-colors break-all',
|
|
40
|
+
assistant: 'text-primary underline hover:text-primary/80 transition-colors break-all',
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** Inline secondary action (e.g. "Show more / less"). Same logic as anchors. */
|
|
44
|
+
export const TOGGLE = {
|
|
45
|
+
user: 'text-primary-foreground/80 hover:text-primary-foreground',
|
|
46
|
+
assistant: 'text-primary hover:text-primary/80',
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
/** Destructive surface — used by ErrorBanner and the delete action. */
|
|
50
|
+
export const DESTRUCTIVE_SURFACE = {
|
|
51
|
+
/** Banner / card variant: border + tint + text. */
|
|
52
|
+
banner:
|
|
53
|
+
'border border-destructive/40 bg-destructive/10 text-destructive',
|
|
54
|
+
/** Subtle hover for destructive buttons inside the banner / menu. */
|
|
55
|
+
hover: 'hover:bg-destructive/15',
|
|
56
|
+
/** Strong-hover variant (e.g. trash overlay on attachments). */
|
|
57
|
+
hoverStrong:
|
|
58
|
+
'hover:bg-destructive hover:text-destructive-foreground',
|
|
59
|
+
/** Inline destructive text utility. */
|
|
60
|
+
text: 'text-destructive',
|
|
61
|
+
/** Hover style for menu items that delete data. */
|
|
62
|
+
menuItem:
|
|
63
|
+
'text-destructive focus:text-destructive hover:bg-destructive/15 hover:text-destructive',
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
/** Tool-call result text. */
|
|
67
|
+
export const TOOL_CALL = {
|
|
68
|
+
errorText: 'text-destructive',
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
export type ChatBubbleSurface = keyof typeof BUBBLE_SURFACE;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BUBBLE_SURFACE,
|
|
3
|
+
ANCHOR,
|
|
4
|
+
TOGGLE,
|
|
5
|
+
DESTRUCTIVE_SURFACE,
|
|
6
|
+
TOOL_CALL,
|
|
7
|
+
type ChatBubbleSurface,
|
|
8
|
+
} from './bubbleTokens';
|
|
9
|
+
export {
|
|
10
|
+
useChatBubbleStyles,
|
|
11
|
+
useChatRoleStyles,
|
|
12
|
+
useChatDestructiveStyles,
|
|
13
|
+
type ChatBubbleStyles,
|
|
14
|
+
type ChatRoleStyles,
|
|
15
|
+
type ChatDestructiveStyles,
|
|
16
|
+
} from './useChatStyles';
|