@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,119 @@
|
|
|
1
|
+
import type { ChatMessage } from '../types';
|
|
2
|
+
|
|
3
|
+
export type TitleMode = 'rotate' | 'prefix';
|
|
4
|
+
|
|
5
|
+
export interface TitleRotatorOptions {
|
|
6
|
+
/** Snapshot taken on first `start()` if omitted. */
|
|
7
|
+
base?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Build the alert string for the current unread state.
|
|
10
|
+
* Only used in `rotate` mode. Default: `"(N) Base"` — same shape as
|
|
11
|
+
* the prefix mode, so the two modes differ only in *whether* the
|
|
12
|
+
* title rotates, not in what it says.
|
|
13
|
+
*/
|
|
14
|
+
template?: (count: number, latest?: ChatMessage | null, base?: string) => string;
|
|
15
|
+
/** Only used in `rotate` mode. Default 2000ms — gentle, not nervous. */
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* - `prefix` (default): render `"(N) Base"` once, no interval. The
|
|
19
|
+
* restrained Facebook-style cue: rely on the favicon dot for
|
|
20
|
+
* attention, keep the title readable.
|
|
21
|
+
* - `rotate`: swap between base and `template(...)` every
|
|
22
|
+
* `intervalMs`. Use when the host has no favicon (or the user
|
|
23
|
+
* pinned the tab and favicons are hidden).
|
|
24
|
+
*/
|
|
25
|
+
mode?: TitleMode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RotatorHandle {
|
|
29
|
+
start(count: number, latest?: ChatMessage | null): void;
|
|
30
|
+
update(count: number, latest?: ChatMessage | null): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TEMPLATE = (count: number, _latest?: ChatMessage | null, base?: string) =>
|
|
35
|
+
base ? `(${count}) ${base}` : `(${count})`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mutates `document.title`. SSR-safe (returns no-op handle).
|
|
39
|
+
*
|
|
40
|
+
* Invariants:
|
|
41
|
+
* - `stop()` always restores the base title.
|
|
42
|
+
* - Multiple `start()` calls without an intervening `stop()` are
|
|
43
|
+
* equivalent to `update()`; we never stack timers.
|
|
44
|
+
* - In `rotate` mode the timer only ticks when the page is hidden
|
|
45
|
+
* (the caller is expected to gate this; see `useChatUnreadNotifier`).
|
|
46
|
+
*/
|
|
47
|
+
export function createTitleRotator(opts: TitleRotatorOptions = {}): RotatorHandle {
|
|
48
|
+
if (typeof document === 'undefined') {
|
|
49
|
+
return { start() {}, update() {}, stop() {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const template = opts.template ?? DEFAULT_TEMPLATE;
|
|
53
|
+
const intervalMs = opts.intervalMs ?? 2000;
|
|
54
|
+
const mode: TitleMode = opts.mode ?? 'prefix';
|
|
55
|
+
|
|
56
|
+
let baseTitle: string | null = null;
|
|
57
|
+
let alertTitle = '';
|
|
58
|
+
let phase: 'base' | 'alert' = 'base';
|
|
59
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
|
|
61
|
+
const captureBaseOnce = () => {
|
|
62
|
+
if (baseTitle !== null) return;
|
|
63
|
+
baseTitle = opts.base ?? document.title;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const render = () => {
|
|
67
|
+
if (baseTitle === null) return;
|
|
68
|
+
document.title = phase === 'alert' ? alertTitle : baseTitle;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const tick = () => {
|
|
72
|
+
phase = phase === 'alert' ? 'base' : 'alert';
|
|
73
|
+
render();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const prefixTitle = (count: number) =>
|
|
77
|
+
baseTitle ? `(${count}) ${baseTitle}` : `(${count})`;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
start(count, latest) {
|
|
81
|
+
captureBaseOnce();
|
|
82
|
+
alertTitle = template(count, latest, baseTitle ?? undefined);
|
|
83
|
+
|
|
84
|
+
if (mode === 'prefix') {
|
|
85
|
+
document.title = prefixTitle(count);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
phase = 'alert';
|
|
90
|
+
render();
|
|
91
|
+
if (timer === null) {
|
|
92
|
+
timer = setInterval(tick, intervalMs);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
update(count, latest) {
|
|
97
|
+
// Same path as start — base title is already captured, just refresh
|
|
98
|
+
// the alert string and re-render in case we're in alert phase.
|
|
99
|
+
captureBaseOnce();
|
|
100
|
+
alertTitle = template(count, latest, baseTitle ?? undefined);
|
|
101
|
+
if (mode === 'prefix') {
|
|
102
|
+
document.title = prefixTitle(count);
|
|
103
|
+
} else if (phase === 'alert') {
|
|
104
|
+
render();
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
if (timer !== null) {
|
|
110
|
+
clearInterval(timer);
|
|
111
|
+
timer = null;
|
|
112
|
+
}
|
|
113
|
+
if (baseTitle !== null) {
|
|
114
|
+
document.title = baseTitle;
|
|
115
|
+
}
|
|
116
|
+
phase = 'base';
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ChatMessage } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transport-agnostic surface for surfacing "you have new messages" to the
|
|
5
|
+
* user when they're not actively watching the chat.
|
|
6
|
+
*
|
|
7
|
+
* Two reference implementations ship with ui-tools:
|
|
8
|
+
* - `createBrowserNotifier` — Facebook-style document.title rotation +
|
|
9
|
+
* favicon badge, suitable for browser tabs.
|
|
10
|
+
* - `createNoopNotifier` — does nothing; default when no DOM is around
|
|
11
|
+
* and the safe pick inside Wails / Electron, where the host wires
|
|
12
|
+
* its own native dock-badge bridge instead.
|
|
13
|
+
*
|
|
14
|
+
* Hosts that want native badges (Wails dock, macOS notification dot)
|
|
15
|
+
* implement this interface themselves and pass the instance into
|
|
16
|
+
* `useChatUnreadNotifier({ notifier })`.
|
|
17
|
+
*/
|
|
18
|
+
export interface ChatNotifier {
|
|
19
|
+
/**
|
|
20
|
+
* Called whenever the unread count is non-zero AND the page is not
|
|
21
|
+
* visible to the user. Implementations must be idempotent — they may
|
|
22
|
+
* be called repeatedly with the same count.
|
|
23
|
+
*/
|
|
24
|
+
setUnread(count: number, latest?: ChatMessage | null): void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Called when the user is back (page visible, or dock opened) OR when
|
|
28
|
+
* unread drops to zero. Implementations must fully restore any state
|
|
29
|
+
* they mutated (title, favicon).
|
|
30
|
+
*/
|
|
31
|
+
clear(): void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optional: release any resources (DOM listeners, cached canvases).
|
|
35
|
+
* Called on hook unmount. Safe to omit.
|
|
36
|
+
*/
|
|
37
|
+
dispose?(): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-visibility helpers. SSR-safe — every entry point guards on
|
|
3
|
+
* `typeof document`.
|
|
4
|
+
*
|
|
5
|
+
* Why `hidden || !hasFocus` instead of just `hidden`:
|
|
6
|
+
* - `document.hidden` flips true only when the tab is fully
|
|
7
|
+
* backgrounded (other tab, minimised window).
|
|
8
|
+
* - A focused window in the foreground with the chat tab visible but
|
|
9
|
+
* the user typing into a different app still counts as "watching"
|
|
10
|
+
* for Chrome's purposes. We treat that as away too, so unread
|
|
11
|
+
* notifications kick in when you alt-tab to your editor.
|
|
12
|
+
*
|
|
13
|
+
* Inside Wails / Electron with a single window, `document.hidden` is
|
|
14
|
+
* always false and `document.hasFocus()` tracks the OS window — which
|
|
15
|
+
* is exactly what we want for a desktop dock badge if a host ever
|
|
16
|
+
* decides to reuse the browser notifier (most won't; they'll ship
|
|
17
|
+
* their own ChatNotifier).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function isPageHidden(): boolean {
|
|
21
|
+
if (typeof document === 'undefined') return false;
|
|
22
|
+
if (document.hidden) return true;
|
|
23
|
+
if (typeof document.hasFocus === 'function' && !document.hasFocus()) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subscribe to visibility/focus changes. Returns an unsubscribe fn.
|
|
29
|
+
* No-op in SSR.
|
|
30
|
+
*/
|
|
31
|
+
export function onVisibilityChange(cb: (hidden: boolean) => void): () => void {
|
|
32
|
+
if (typeof document === 'undefined') return () => {};
|
|
33
|
+
|
|
34
|
+
const fire = () => cb(isPageHidden());
|
|
35
|
+
document.addEventListener('visibilitychange', fire);
|
|
36
|
+
// `focus` / `blur` on window catch alt-tab-without-visibility-change
|
|
37
|
+
// (Chrome on macOS notably keeps `visibilityState === 'visible'` for
|
|
38
|
+
// background-but-on-screen tabs).
|
|
39
|
+
window.addEventListener('focus', fire);
|
|
40
|
+
window.addEventListener('blur', fire);
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
document.removeEventListener('visibilitychange', fire);
|
|
44
|
+
window.removeEventListener('focus', fire);
|
|
45
|
+
window.removeEventListener('blur', fire);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -156,8 +156,8 @@ export function ImageViewer({
|
|
|
156
156
|
className={cn(
|
|
157
157
|
'flex-1 h-full relative overflow-hidden outline-none',
|
|
158
158
|
'bg-[length:16px_16px]',
|
|
159
|
-
'[background-color:
|
|
160
|
-
'[background-image:linear-gradient(45deg,
|
|
159
|
+
'[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
|
|
160
|
+
'[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
|
|
161
161
|
'[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
|
|
162
162
|
)}
|
|
163
163
|
>
|
|
@@ -88,13 +88,13 @@
|
|
|
88
88
|
|
|
89
89
|
/* Focus ring */
|
|
90
90
|
.markdown-editor:focus-within {
|
|
91
|
-
border-color: var(--color-ring,
|
|
92
|
-
box-shadow: 0 0 0 2px var(--color-ring,
|
|
91
|
+
border-color: var(--color-ring, var(--ring));
|
|
92
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-ring, var(--ring)) 30%, transparent);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/* Mention inline chip */
|
|
96
96
|
.markdown-mention {
|
|
97
|
-
background: var(--color-primary,
|
|
97
|
+
background: var(--color-primary, var(--primary));
|
|
98
98
|
color: var(--color-primary-foreground, #fff);
|
|
99
99
|
padding: 1px 6px;
|
|
100
100
|
border-radius: 4px;
|
|
@@ -105,9 +105,9 @@
|
|
|
105
105
|
|
|
106
106
|
/* Mention dropdown */
|
|
107
107
|
.markdown-mention-list {
|
|
108
|
-
background: var(--color-popover,
|
|
109
|
-
color: var(--color-popover-foreground,
|
|
110
|
-
border: 1px solid var(--color-border,
|
|
108
|
+
background: var(--color-popover, var(--popover));
|
|
109
|
+
color: var(--color-popover-foreground, var(--popover-foreground));
|
|
110
|
+
border: 1px solid var(--color-border, var(--border));
|
|
111
111
|
border-radius: 8px;
|
|
112
112
|
padding: 4px;
|
|
113
113
|
min-width: 220px;
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
|
|
134
134
|
.markdown-mention-item:hover,
|
|
135
135
|
.markdown-mention-item.selected {
|
|
136
|
-
background: var(--color-muted,
|
|
136
|
+
background: var(--color-muted, var(--muted));
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
.markdown-mention-avatar {
|
|
@@ -223,7 +223,7 @@ export function countryFromTag(tag: string | null | undefined): string | null {
|
|
|
223
223
|
const parts = tag.split('-');
|
|
224
224
|
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
|
225
225
|
const p = parts[i];
|
|
226
|
-
if (p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
|
|
226
|
+
if (p && p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
|
|
227
227
|
}
|
|
228
228
|
return null;
|
|
229
229
|
}
|
|
@@ -31,7 +31,7 @@ export function useMicLevel(stream: MediaStream | null): number {
|
|
|
31
31
|
const tick = (): void => {
|
|
32
32
|
analyser.getFloatTimeDomainData(buf);
|
|
33
33
|
let sum = 0;
|
|
34
|
-
for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i]
|
|
34
|
+
for (let i = 0; i < buf.length; i += 1) sum += buf[i]! * buf[i]!;
|
|
35
35
|
const rms = Math.sqrt(sum / buf.length);
|
|
36
36
|
// soft compression so loud peaks don't dominate the meter
|
|
37
37
|
setLevel(Math.min(1, rms * 2.5));
|
|
@@ -97,7 +97,7 @@ export function useSpeechLanguageInfo(): SpeechLanguageInfo {
|
|
|
97
97
|
const found = findSpeechLanguage(tag);
|
|
98
98
|
return {
|
|
99
99
|
tag,
|
|
100
|
-
iso: found?.language.iso ?? tag.split('-')[0].toLowerCase(),
|
|
100
|
+
iso: found?.language.iso ?? (tag.split('-')[0] ?? tag).toLowerCase(),
|
|
101
101
|
country: countryFromTag(tag),
|
|
102
102
|
name: found?.language.name ?? null,
|
|
103
103
|
englishName: found?.language.englishName ?? null,
|