@djangocfg/widget-assistant 0.3.2 → 0.3.8
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 +18 -0
- package/package.json +7 -7
- package/src/AssistantChat.tsx +14 -4
- package/src/composer/ComposerBehaviour.tsx +17 -0
- package/src/composer/ComposerHelp.tsx +4 -1
- package/src/composer/earcons.ts +49 -0
- package/src/composer/handoff.ts +26 -0
- package/src/index.ts +1 -0
- package/src/sessions/SessionsMenu.tsx +34 -11
- package/src/shell/AssistantFrame.tsx +11 -12
- package/src/shell/AssistantLocked.tsx +12 -4
- package/src/shell/ChatDock.tsx +16 -27
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@ It knows nothing about one site (`src/boundary.test.ts` enforces it), so what a
|
|
|
7
7
|
<AssistantChat adapter={adapter} suggestions={suggestions} dataRenderers={renderers} />
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
## Docs
|
|
11
|
+
|
|
12
|
+
Longer pages live in [`@docs/`](./@docs/README.md) (repository only, not in the npm package): architecture, the wire contract, the Python server half, connecting a new project, customization, troubleshooting. The server half is not in django-cfg's config: each product writes its own on the agent plane (`@docs/server.md`).
|
|
13
|
+
|
|
10
14
|
## Folders (one job each)
|
|
11
15
|
|
|
12
16
|
| Folder | Job |
|
|
@@ -58,6 +62,20 @@ A session is one thread on the server. `useSession({ transport, api, storage, ge
|
|
|
58
62
|
* There is no delete and no `clear()`: "clear" is "new chat". Retention is the server's job.
|
|
59
63
|
* Without `api` (anonymous host) nothing is listed; `startNew()` just empties the room.
|
|
60
64
|
|
|
65
|
+
## Voice (0.3.3)
|
|
66
|
+
|
|
67
|
+
The microphone menu always offers a dictation language: a visitor may speak any language, whatever the host UI says. With no `speech` prop the assistant uses `useSpeechLanguage` from `@djangocfg/widget-chat/store` (the last pick, kept on this device; else the page language if the browser also lists it; else the browser's; else English). A host `speech={{ value, onChange }}` still overrides it.
|
|
68
|
+
|
|
69
|
+
Message sounds (sent, answer starts, answer done, error) come from `widget-chat` and are on by default; `messageSounds={false}` silences them, `messageSounds={{ volume, urls }}` tunes them.
|
|
70
|
+
|
|
71
|
+
Dictation start/stop tones are bundled (Web Audio, no files). `sounds={false}` silences them; `sounds={{ play(kind) }}` replaces them.
|
|
72
|
+
|
|
73
|
+
## Asking from outside, the dock and the locked room
|
|
74
|
+
|
|
75
|
+
* `askAssistant(text)` puts a prompt typed elsewhere on the page (a hero search, a "discuss this" button) into the composer and sends it once the composer is mounted and sending is allowed. One slot: asking twice before the composer exists keeps the last.
|
|
76
|
+
* `AssistantFrame` and `ChatDock` take `actions` (header controls such as `SessionsMenu`): they sit next to Close in the one panel header (`ChatPanelHeader` from `widget-chat`, drawn identically by the wide column, the tablet side panel and the phone sheet), set apart by a thin divider; each button has a ui-core tooltip. `ChatDock` is a side panel on tablets, the base package's `ChatSheet` on phones. The keys list (`ComposerHelp`) is hidden on touch devices.
|
|
77
|
+
* `AssistantLocked` takes `onComposerClick`: the inert composer becomes a button for the way out (sign in).
|
|
78
|
+
|
|
61
79
|
## Tests
|
|
62
80
|
|
|
63
81
|
`pnpm --filter @djangocfg/widget-assistant check test`: the wire, stream and boundary tests, and `tsc --noEmit`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/widget-assistant",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "A site assistant on top of @djangocfg/widget-chat: the SSE wire and session layer, a persistent host, docked column, floating button and sheet, dictation, Help and 17 built-in locales",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chat",
|
|
@@ -44,19 +44,19 @@
|
|
|
44
44
|
"test": "vitest run"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@djangocfg/widget-chat": "^0.1.
|
|
47
|
+
"@djangocfg/widget-chat": "^0.1.24",
|
|
48
48
|
"zustand": "^5.0.16"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@djangocfg/i18n": "^2.1.
|
|
52
|
-
"@djangocfg/ui-core": "^2.1.
|
|
51
|
+
"@djangocfg/i18n": "^2.1.623",
|
|
52
|
+
"@djangocfg/ui-core": "^2.1.623",
|
|
53
53
|
"lucide-react": "^1.68.0",
|
|
54
54
|
"react": "^19.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@djangocfg/i18n": "^2.1.
|
|
58
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
59
|
-
"@djangocfg/ui-core": "^2.1.
|
|
57
|
+
"@djangocfg/i18n": "^2.1.623",
|
|
58
|
+
"@djangocfg/typescript-config": "^2.1.623",
|
|
59
|
+
"@djangocfg/ui-core": "^2.1.623",
|
|
60
60
|
"@storybook/react-vite": "^10.6.5",
|
|
61
61
|
"@types/node": "^25.9.5",
|
|
62
62
|
"@types/react": "19.3.0",
|
package/src/AssistantChat.tsx
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo, useRef, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
+
import type { ChatSoundsCapability, VoiceSoundsCapability } from '@djangocfg/widget-chat/core';
|
|
6
|
+
import { useSpeechLanguage } from '@djangocfg/widget-chat/store';
|
|
5
7
|
import {
|
|
6
8
|
Chat,
|
|
7
9
|
ChatTranscript,
|
|
@@ -14,6 +16,8 @@ import {
|
|
|
14
16
|
import { Response } from '@djangocfg/widget-chat/content';
|
|
15
17
|
|
|
16
18
|
import type { SessionNotice } from './stream/useSession';
|
|
19
|
+
import { useAssistantLocale } from './i18n';
|
|
20
|
+
import { bundledEarcons } from './composer/earcons';
|
|
17
21
|
import { createWholeTranscriptSpeech } from './composer/wholeTranscript';
|
|
18
22
|
import { AssistantComposer } from './composer/AssistantComposer';
|
|
19
23
|
import { ComposerHelp } from './composer/ComposerHelp';
|
|
@@ -39,8 +43,12 @@ interface Props {
|
|
|
39
43
|
draftRoom?: string;
|
|
40
44
|
/** The host's drawing of structured parts, by `kind` (for example `citations`). Unknown kinds fall through to the widget. */
|
|
41
45
|
dataRenderers?: Readonly<Record<string, DataPartRenderer>>;
|
|
42
|
-
/** Dictation language and its picker (the microphone menu). Absent: the
|
|
46
|
+
/** Dictation language and its picker (the microphone menu). Absent: the assistant's own picker (the viewer's last pick, else the page and browser language), always shown. */
|
|
43
47
|
speech?: SpeechLanguage;
|
|
48
|
+
/** The dictation start/stop tones. Absent: the bundled ones (Web Audio, no files). `false`: silent. */
|
|
49
|
+
sounds?: VoiceSoundsCapability | false;
|
|
50
|
+
/** The message sounds (sent, answer starts, answer done, error). Absent: the widget's bundled ones, on. `false`: silent. */
|
|
51
|
+
messageSounds?: ChatSoundsCapability | false;
|
|
44
52
|
/** A plain line above the composer: reconnecting, or a stalled run with its Retry (`onRetry`). */
|
|
45
53
|
notice?: SessionNotice | null;
|
|
46
54
|
onRetry?: () => void;
|
|
@@ -59,7 +67,7 @@ const NO_RENDERERS: Readonly<Record<string, DataPartRenderer>> = {};
|
|
|
59
67
|
* recall of sent prompts with the arrow keys, an unsent draft that survives a reload). The widget's packaged layout shows only a bare composer, so the layout
|
|
60
68
|
* is composed here from its own primitives: every behaviour is the widget's, nothing is re-implemented.
|
|
61
69
|
*/
|
|
62
|
-
export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, draftRoom, dataRenderers = NO_RENDERERS, speech, notice, onRetry, focusWhen, refocus, className }: Props) {
|
|
70
|
+
export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, draftRoom, dataRenderers = NO_RENDERERS, speech: given, sounds, messageSounds, notice, onRetry, focusWhen, refocus, className }: Props) {
|
|
63
71
|
const renderers = useMemo(
|
|
64
72
|
() => ({
|
|
65
73
|
text: ({ part }: PartRenderProps) => (part.type === 'text' ? <Response>{part.text}</Response> : null),
|
|
@@ -74,13 +82,15 @@ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM,
|
|
|
74
82
|
}),
|
|
75
83
|
[dataRenderers],
|
|
76
84
|
);
|
|
85
|
+
const own = useSpeechLanguage({ uiLocale: useAssistantLocale() });
|
|
86
|
+
const speech = given ?? { value: own.language, onChange: own.setLanguage };
|
|
77
87
|
// The language is read at each start, through a ref: the capability must not be rebuilt when the picker changes.
|
|
78
88
|
const language = useRef(speech?.value);
|
|
79
89
|
language.current = speech?.value;
|
|
80
90
|
const voice = useMemo(() => createWholeTranscriptSpeech(() => language.current ?? (typeof navigator === 'undefined' ? 'en-US' : navigator.language)), []);
|
|
81
91
|
const capabilities = useMemo<ChatCapabilities>(
|
|
82
|
-
() => ({ suggestions: { items: suggestions.map((prompt, index) => ({ id: `s${index}`, title: prompt, prompt })) }, ...(voice ? { voice } : {}) }),
|
|
83
|
-
[suggestions, voice],
|
|
92
|
+
() => ({ suggestions: { items: suggestions.map((prompt, index) => ({ id: `s${index}`, title: prompt, prompt })) }, ...(voice ? { voice } : {}), ...(sounds === false ? {} : { voiceSounds: sounds ?? bundledEarcons }), ...(messageSounds === undefined ? {} : { sounds: messageSounds }) }),
|
|
93
|
+
[suggestions, voice, sounds, messageSounds],
|
|
84
94
|
);
|
|
85
95
|
const slots = useMemo(
|
|
86
96
|
() => ({
|
|
@@ -6,6 +6,7 @@ import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
|
6
6
|
import { useComposer } from '@djangocfg/widget-chat';
|
|
7
7
|
|
|
8
8
|
import { useCaretFocus } from './useCaretFocus';
|
|
9
|
+
import { clearPendingPrompt, usePendingPrompt } from './handoff';
|
|
9
10
|
import { useComposerNodes } from './useComposerNodes';
|
|
10
11
|
import { DICTATION_TOGGLE } from './keys';
|
|
11
12
|
import { useFocusArea } from './useFocusArea';
|
|
@@ -33,6 +34,22 @@ export function ComposerBehaviour({ focusWhen = false, refocus = false }: Props)
|
|
|
33
34
|
const latest = useRef(composer);
|
|
34
35
|
latest.current = composer;
|
|
35
36
|
|
|
37
|
+
// A prompt asked from outside the assistant (`askAssistant`): into the draft, then sent once the draft holds it and sending is allowed.
|
|
38
|
+
const pendingPrompt = usePendingPrompt();
|
|
39
|
+
const draftText = composer.draft.text;
|
|
40
|
+
const canSend = composer.canSend;
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!pendingPrompt) return;
|
|
43
|
+
if (draftText !== pendingPrompt) {
|
|
44
|
+
latest.current.setText(pendingPrompt);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (canSend) {
|
|
48
|
+
clearPendingPrompt();
|
|
49
|
+
latest.current.send();
|
|
50
|
+
}
|
|
51
|
+
}, [pendingPrompt, draftText, canSend]);
|
|
52
|
+
|
|
36
53
|
const recording = useRecording(nodes);
|
|
37
54
|
useHeightFloor(nodes, recording);
|
|
38
55
|
useFocusArea(nodes, marker, refocus);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Kbd, Popover, PopoverContent, PopoverTrigger } from '@djangocfg/ui-core/components';
|
|
4
|
-
import { formatHotkey } from '@djangocfg/ui-core/hooks';
|
|
4
|
+
import { formatHotkey, useIsTouch } from '@djangocfg/ui-core/hooks';
|
|
5
5
|
|
|
6
6
|
import { useAssistantCopy } from '../i18n';
|
|
7
7
|
import { DICTATION_TOGGLE } from './keys';
|
|
@@ -22,6 +22,9 @@ function useRows() {
|
|
|
22
22
|
export function ComposerHelp() {
|
|
23
23
|
const copy = useAssistantCopy();
|
|
24
24
|
const rows = useRows();
|
|
25
|
+
const touch = useIsTouch();
|
|
26
|
+
// The keys are a desktop keyboard's: a phone has none of them.
|
|
27
|
+
if (touch) return null;
|
|
25
28
|
return (
|
|
26
29
|
<div className="px-4 pb-1 text-end">
|
|
27
30
|
<Popover>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { VoiceSoundsCapability } from '@djangocfg/widget-chat/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The dictation tones, made with the Web Audio API: a rising pair when the microphone opens, a falling pair when it closes. No audio files ship and none
|
|
5
|
+
* are fetched. A tone is quiet and short; a browser that has no `AudioContext`, or has not been unlocked by a gesture, plays nothing (the dictation starts from a click, so it is unlocked).
|
|
6
|
+
*/
|
|
7
|
+
const TONES: Readonly<Record<'start' | 'stop', readonly [number, number]>> = {
|
|
8
|
+
start: [660, 880],
|
|
9
|
+
stop: [880, 560],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const STEP = 0.07;
|
|
13
|
+
const GAIN = 0.06;
|
|
14
|
+
|
|
15
|
+
let context: AudioContext | null | undefined;
|
|
16
|
+
|
|
17
|
+
function audio(): AudioContext | null {
|
|
18
|
+
if (context !== undefined) return context;
|
|
19
|
+
const Ctor = typeof window === 'undefined' ? undefined : (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
|
|
20
|
+
context = Ctor ? new Ctor() : null;
|
|
21
|
+
return context;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The bundled tones. Never throws: the session runs this on the path that opens the microphone. */
|
|
25
|
+
export const bundledEarcons: VoiceSoundsCapability = {
|
|
26
|
+
play: (kind) => {
|
|
27
|
+
try {
|
|
28
|
+
const ctx = audio();
|
|
29
|
+
if (!ctx) return;
|
|
30
|
+
if (ctx.state === 'suspended') void ctx.resume();
|
|
31
|
+
const [first, second] = TONES[kind];
|
|
32
|
+
const now = ctx.currentTime;
|
|
33
|
+
[first, second].forEach((frequency, index) => {
|
|
34
|
+
const osc = ctx.createOscillator();
|
|
35
|
+
const gain = ctx.createGain();
|
|
36
|
+
const at = now + index * STEP;
|
|
37
|
+
osc.frequency.value = frequency;
|
|
38
|
+
gain.gain.setValueAtTime(0, at);
|
|
39
|
+
gain.gain.linearRampToValueAtTime(GAIN, at + 0.01);
|
|
40
|
+
gain.gain.linearRampToValueAtTime(0, at + STEP);
|
|
41
|
+
osc.connect(gain).connect(ctx.destination);
|
|
42
|
+
osc.start(at);
|
|
43
|
+
osc.stop(at + STEP + 0.02);
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
// A tone is a convenience.
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A prompt typed outside the assistant (the header's AI mode) waiting for the composer. The composer, once mounted, puts it in the draft and
|
|
5
|
+
* sends it, then clears it. One slot: asking twice before the composer exists keeps the last.
|
|
6
|
+
*/
|
|
7
|
+
let pending: string | null = null;
|
|
8
|
+
const listeners = new Set<() => void>();
|
|
9
|
+
const emit = () => listeners.forEach((listener) => listener());
|
|
10
|
+
|
|
11
|
+
export function askAssistant(text: string): void {
|
|
12
|
+
pending = text.trim() || null;
|
|
13
|
+
emit();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clearPendingPrompt(): void {
|
|
17
|
+
pending = null;
|
|
18
|
+
emit();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const subscribe = (listener: () => void) => {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => void listeners.delete(listener);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const usePendingPrompt = (): string | null => useSyncExternalStore(subscribe, () => pending, () => null);
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AssistantChat, type DataPartRenderer } from './AssistantChat';
|
|
2
2
|
export { scriptedTransport, type ScriptStep } from './stream/scriptedTransport';
|
|
3
3
|
export { useAssistantCopy, useAssistantLocale, ASSISTANT_DICTIONARIES, type AssistantCopy } from './i18n';
|
|
4
|
+
export { askAssistant } from './composer/handoff';
|
|
4
5
|
export { ChatDock } from './shell/ChatDock';
|
|
5
6
|
export { AssistantFrame, type FrameLabels } from './shell/AssistantFrame';
|
|
6
7
|
export { AssistantFab, type FabHint } from './shell/AssistantFab';
|
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check, History, Plus } from 'lucide-react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
Tooltip,
|
|
13
|
+
TooltipContent,
|
|
14
|
+
TooltipProvider,
|
|
15
|
+
TooltipTrigger,
|
|
16
|
+
} from '@djangocfg/ui-core/components';
|
|
6
17
|
|
|
7
18
|
import { useAssistantCopy, useAssistantLocale } from '../i18n';
|
|
8
19
|
import type { Session } from '../stream/useSession';
|
|
@@ -17,7 +28,7 @@ interface Props {
|
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
/**
|
|
20
|
-
* The chat's header controls: "New chat" (a new session; the old ones stay) and the list of earlier sessions, newest first, to switch back.
|
|
31
|
+
* The chat's header controls (put them in the frame's or the dock's `actions`, next to Close; each has a tooltip): "New chat" (a new session; the old ones stay) and the list of earlier sessions, newest first, to switch back.
|
|
21
32
|
* Both wait while an answer streams: Stop ends it first. Words come from the assistant's own dictionaries.
|
|
22
33
|
*/
|
|
23
34
|
export function SessionsMenu({ session, locale, newOnly = false }: Props) {
|
|
@@ -25,17 +36,28 @@ export function SessionsMenu({ session, locale, newOnly = false }: Props) {
|
|
|
25
36
|
const active = useAssistantLocale(locale);
|
|
26
37
|
const current = session.thread.kind === 'ready' ? session.thread.session : null;
|
|
27
38
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
<TooltipProvider delayDuration={300}>
|
|
40
|
+
<div className="flex items-center gap-1" data-testid="sessions-menu">
|
|
41
|
+
<Tooltip>
|
|
42
|
+
<TooltipTrigger asChild>
|
|
43
|
+
<Button variant="ghost" size="icon" aria-label={copy.newChat} disabled={session.busy} onClick={() => void session.startNew()}>
|
|
44
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
45
|
+
</Button>
|
|
46
|
+
</TooltipTrigger>
|
|
47
|
+
<TooltipContent>{copy.newChat}</TooltipContent>
|
|
48
|
+
</Tooltip>
|
|
32
49
|
{newOnly ? null : (
|
|
33
50
|
<DropdownMenu onOpenChange={(open) => open && session.refreshSessions()}>
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
<Tooltip>
|
|
52
|
+
<TooltipTrigger asChild>
|
|
53
|
+
<DropdownMenuTrigger asChild>
|
|
54
|
+
<Button variant="ghost" size="icon" aria-label={copy.sessions}>
|
|
55
|
+
<History className="size-4" aria-hidden="true" />
|
|
56
|
+
</Button>
|
|
57
|
+
</DropdownMenuTrigger>
|
|
58
|
+
</TooltipTrigger>
|
|
59
|
+
<TooltipContent>{copy.sessions}</TooltipContent>
|
|
60
|
+
</Tooltip>
|
|
39
61
|
<DropdownMenuContent align="end" sideOffset={4} className="max-h-96 w-72 overflow-y-auto">
|
|
40
62
|
<DropdownMenuLabel>{copy.sessions}</DropdownMenuLabel>
|
|
41
63
|
{session.sessions === null ? null : session.sessions.length === 0 ? (
|
|
@@ -55,5 +77,6 @@ export function SessionsMenu({ session, locale, newOnly = false }: Props) {
|
|
|
55
77
|
</DropdownMenu>
|
|
56
78
|
)}
|
|
57
79
|
</div>
|
|
80
|
+
</TooltipProvider>
|
|
58
81
|
);
|
|
59
82
|
}
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { Button } from '@djangocfg/ui-core/components';
|
|
5
|
+
import { ChatPanelHeader, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
|
|
8
6
|
|
|
9
7
|
import { AssistantSlot } from '../Host';
|
|
10
8
|
import { useAssistantCopy } from '../i18n';
|
|
@@ -28,6 +26,8 @@ interface Props {
|
|
|
28
26
|
onWidthChange: (width: number) => void;
|
|
29
27
|
onClose: () => void;
|
|
30
28
|
labels: FrameLabels;
|
|
29
|
+
/** Header controls beside the close button (for example `SessionsMenu`): one header, a thin divider keeps them apart from Close. */
|
|
30
|
+
actions?: ReactNode;
|
|
31
31
|
/** What the column shows instead of the chat (the locked view, a notice). Absent: the chat itself. */
|
|
32
32
|
content?: ReactNode;
|
|
33
33
|
/** The page. */
|
|
@@ -47,7 +47,7 @@ const RESTORE_GRACE_MS = 800;
|
|
|
47
47
|
* reflows), with a short fade. `--assistant-w` is a registered length (`layout.css`), so fixed host elements follow the same curve. The column stays mounted until the
|
|
48
48
|
* close finishes. Resizing by hand and the first paint after a page load are not animated.
|
|
49
49
|
*/
|
|
50
|
-
export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, children }: Props) {
|
|
50
|
+
export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions, children }: Props) {
|
|
51
51
|
const born = useRef(0);
|
|
52
52
|
const [mounted, setMounted] = useState(false);
|
|
53
53
|
const [shown, setShown] = useState(false);
|
|
@@ -90,12 +90,12 @@ export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth
|
|
|
90
90
|
return (
|
|
91
91
|
<div className="assistant-frame flex" data-moving={moving ? 'true' : 'false'} style={style}>
|
|
92
92
|
<div className="min-w-0 flex-1">{children}</div>
|
|
93
|
-
{mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }} /> : null}
|
|
93
|
+
{mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions }} /> : null}
|
|
94
94
|
</div>
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
|
|
98
|
+
function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
|
|
99
99
|
const copy = useAssistantCopy();
|
|
100
100
|
// The handle sits on the column's left edge: the pointer's distance from the window's right edge is the new width.
|
|
101
101
|
const drag = useCallback(
|
|
@@ -140,12 +140,11 @@ function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange,
|
|
|
140
140
|
onDoubleClick={() => onWidthChange(defaultWidth)}
|
|
141
141
|
className="absolute inset-y-0 -left-1 z-10 w-2 cursor-col-resize touch-none outline-none transition-colors hover:bg-primary/30 focus-visible:bg-primary/40"
|
|
142
142
|
/>
|
|
143
|
-
<
|
|
144
|
-
<h2 className=
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
</header>
|
|
143
|
+
<ChatPanelHeader
|
|
144
|
+
heading={<h2 className={PANEL_TITLE_CLASS}>{labels.title}</h2>}
|
|
145
|
+
actions={actions}
|
|
146
|
+
close={<PanelCloseButton label={labels.close ?? copy.close} onClick={onClose} />}
|
|
147
|
+
/>
|
|
149
148
|
{content ? <div className="flex min-h-0 flex-1 flex-col">{content}</div> : <AssistantSlot className="min-h-0 flex-1" />}
|
|
150
149
|
</aside>
|
|
151
150
|
</div>
|
|
@@ -16,13 +16,15 @@ interface Props {
|
|
|
16
16
|
composerLabel?: string;
|
|
17
17
|
/** Replaces the lock. */
|
|
18
18
|
icon?: ReactNode;
|
|
19
|
+
/** Makes the composer a button for the way out (sign in): touching the field is the same ask as the action. */
|
|
20
|
+
onComposerClick?: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* The chat as it looks when the viewer cannot write: a centred icon, one short reason, the action, and a composer that is visibly switched off.
|
|
23
25
|
* The root is a flex item (`flex-1`), not `h-full`: percentage heights do not resolve inside a flex column, and the message collapsed against the header.
|
|
24
26
|
*/
|
|
25
|
-
export function AssistantLocked({ title, body, action, composerLabel, icon }: Props) {
|
|
27
|
+
export function AssistantLocked({ title, body, action, composerLabel, icon, onComposerClick }: Props) {
|
|
26
28
|
const copy = useAssistantCopy();
|
|
27
29
|
return (
|
|
28
30
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
@@ -35,9 +37,15 @@ export function AssistantLocked({ title, body, action, composerLabel, icon }: Pr
|
|
|
35
37
|
{action ? <div className="mt-2">{action}</div> : null}
|
|
36
38
|
</div>
|
|
37
39
|
<div className="px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
|
|
40
|
+
{onComposerClick ? (
|
|
41
|
+
<button type="button" onClick={onComposerClick} className="flex w-full items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-start text-sm text-muted-foreground">
|
|
42
|
+
{composerLabel ?? copy.lockedComposer}
|
|
43
|
+
</button>
|
|
44
|
+
) : (
|
|
45
|
+
<div aria-disabled="true" className="flex select-none items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-sm text-muted-foreground opacity-70">
|
|
46
|
+
{composerLabel ?? copy.lockedComposer}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
41
49
|
</div>
|
|
42
50
|
</div>
|
|
43
51
|
);
|
package/src/shell/ChatDock.tsx
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { Button, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, SidePanel } from '@djangocfg/ui-core/components';
|
|
5
|
+
import { SidePanel } from '@djangocfg/ui-core/components';
|
|
8
6
|
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
import { ChatPanelHeader, ChatSheet, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
|
|
9
8
|
|
|
10
9
|
import { useAssistantCopy } from '../i18n';
|
|
11
10
|
|
|
@@ -19,42 +18,32 @@ interface Props {
|
|
|
19
18
|
contextLine?: string;
|
|
20
19
|
/** Accessible name of the close button on phones. Absent: the bundled word. */
|
|
21
20
|
closeLabel?: string;
|
|
21
|
+
/** Header controls before the close button (for example `SessionsMenu`). */
|
|
22
|
+
actions?: ReactNode;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable),
|
|
25
|
-
export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel }: Props) {
|
|
26
|
-
const copy = useAssistantCopy();
|
|
25
|
+
/** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable), the package's full-screen `ChatSheet` on phones (it owns the keyboard, layer and pinning problems). */
|
|
26
|
+
export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel, actions }: Props) {
|
|
27
27
|
const isMobile = useIsMobile();
|
|
28
|
+
const copy = useAssistantCopy();
|
|
28
29
|
|
|
29
30
|
if (isMobile) {
|
|
30
31
|
return (
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<div>
|
|
35
|
-
<DrawerTitle>{title}</DrawerTitle>
|
|
36
|
-
{contextLine ? <DrawerDescription>{contextLine}</DrawerDescription> : null}
|
|
37
|
-
</div>
|
|
38
|
-
<DrawerClose asChild>
|
|
39
|
-
<Button variant="ghost" size="icon" aria-label={closeLabel ?? copy.close}><X className="size-5" aria-hidden="true" /></Button>
|
|
40
|
-
</DrawerClose>
|
|
41
|
-
</DrawerHeader>
|
|
42
|
-
{/* The drawer wraps its children in a scroller that is as tall as its content (it only stretches with snap points), so `flex-1` cannot fill the sheet:
|
|
43
|
-
the body gets the screen height minus the handle, the header and the bottom padding. */}
|
|
44
|
-
<div className="flex flex-col" style={{ height: 'calc(100dvh - 7.5rem)' }}>{chat}</div>
|
|
45
|
-
</DrawerContent>
|
|
46
|
-
</Drawer>
|
|
32
|
+
<ChatSheet open={open} onOpenChange={onOpenChange} title={title} description={contextLine} closeLabel={closeLabel ?? copy.close} actions={actions}>
|
|
33
|
+
{chat}
|
|
34
|
+
</ChatSheet>
|
|
47
35
|
);
|
|
48
36
|
}
|
|
49
37
|
|
|
50
38
|
return (
|
|
51
39
|
<SidePanel open={open} onOpenChange={onOpenChange}>
|
|
52
40
|
<SidePanel.Content width="min(440px, 90vw)">
|
|
53
|
-
<
|
|
54
|
-
<SidePanel.Title>{title}</SidePanel.Title>
|
|
55
|
-
{contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> :
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
<ChatPanelHeader
|
|
42
|
+
heading={<SidePanel.Title className={PANEL_TITLE_CLASS}>{title}</SidePanel.Title>}
|
|
43
|
+
description={contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> : undefined}
|
|
44
|
+
actions={actions}
|
|
45
|
+
close={<PanelCloseButton label={closeLabel ?? copy.close} onClick={() => onOpenChange(false)} />}
|
|
46
|
+
/>
|
|
58
47
|
<SidePanel.Body>{chat}</SidePanel.Body>
|
|
59
48
|
</SidePanel.Content>
|
|
60
49
|
</SidePanel>
|