@djangocfg/widget-assistant 0.0.0-stage → 0.1.1

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/package.json +72 -4
  4. package/src/AssistantChat.tsx +115 -0
  5. package/src/Host.tsx +57 -0
  6. package/src/composer/AssistantComposer.tsx +19 -0
  7. package/src/composer/ComposerBehaviour.tsx +84 -0
  8. package/src/composer/ComposerHelp.tsx +46 -0
  9. package/src/composer/keys.ts +2 -0
  10. package/src/composer/useCaretFocus.ts +49 -0
  11. package/src/composer/useComposerNodes.ts +14 -0
  12. package/src/composer/useFocusArea.ts +29 -0
  13. package/src/composer/useGracefulStop.ts +63 -0
  14. package/src/composer/useHeightFloor.ts +27 -0
  15. package/src/composer/useRecording.ts +18 -0
  16. package/src/composer/useSentPrompts.ts +20 -0
  17. package/src/composer/wholeTranscript.ts +106 -0
  18. package/src/i18n/index.ts +36 -0
  19. package/src/i18n/locales/ar.ts +34 -0
  20. package/src/i18n/locales/da.ts +34 -0
  21. package/src/i18n/locales/de.ts +34 -0
  22. package/src/i18n/locales/en.ts +34 -0
  23. package/src/i18n/locales/es.ts +34 -0
  24. package/src/i18n/locales/fr.ts +34 -0
  25. package/src/i18n/locales/it.ts +34 -0
  26. package/src/i18n/locales/ja.ts +34 -0
  27. package/src/i18n/locales/ko.ts +34 -0
  28. package/src/i18n/locales/nl.ts +34 -0
  29. package/src/i18n/locales/no.ts +34 -0
  30. package/src/i18n/locales/pl.ts +34 -0
  31. package/src/i18n/locales/pt-BR.ts +34 -0
  32. package/src/i18n/locales/ru.ts +34 -0
  33. package/src/i18n/locales/sv.ts +34 -0
  34. package/src/i18n/locales/tr.ts +34 -0
  35. package/src/i18n/locales/zh.ts +34 -0
  36. package/src/i18n/sessionCopy.ts +30 -0
  37. package/src/i18n/types.ts +42 -0
  38. package/src/index.ts +17 -0
  39. package/src/messages/MessageFooter.tsx +25 -0
  40. package/src/messages/NoticeLine.tsx +32 -0
  41. package/src/shell/AssistantFab.tsx +112 -0
  42. package/src/shell/AssistantFrame.tsx +103 -0
  43. package/src/shell/AssistantLocked.tsx +44 -0
  44. package/src/shell/ChatDock.tsx +62 -0
  45. package/src/shell/panelState.ts +74 -0
  46. package/src/stream/adapter.ts +144 -0
  47. package/src/stream/fromStored.ts +23 -0
  48. package/src/stream/reconcile.ts +45 -0
  49. package/src/stream/scriptedTransport.ts +28 -0
  50. package/src/stream/useSession.ts +136 -0
  51. package/src/styles/composer.css +14 -0
  52. package/src/styles/fab.css +53 -0
  53. package/src/styles/index.css +6 -0
  54. package/src/styles/layout.css +15 -0
  55. package/src/styles/widget.css +7 -0
  56. package/src/wire/frames.ts +53 -0
  57. package/src/wire/parseSSE.ts +76 -0
  58. package/src/wire/stored.ts +24 -0
  59. package/src/wire/transport.ts +94 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reforms.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # @djangocfg/widget-assistant
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The assistant chat for a site: the SSE wire and session, the persistent host, the docked column, the floating button and the phone sheet, on top of `@djangocfg/widget-chat`.
4
+ It knows nothing about one site (`src/boundary.test.ts` enforces it), so what a host passes stays small: **a language (from the framework's locale provider), an adapter, suggestions and its own part renderers.** Source-only: the host transpiles it (`DEFAULT_TRANSPILE_PACKAGES`) and imports `@djangocfg/widget-assistant/styles` once.
5
+
6
+ ```tsx
7
+ <AssistantChat adapter={adapter} suggestions={suggestions} dataRenderers={renderers} />
8
+ ```
9
+
10
+ ## Folders (one job each)
11
+
12
+ | Folder | Job |
13
+ |---|---|
14
+ | `wire/` | the HTTP and SSE contract: frames, parser, transport, stored conversation |
15
+ | `stream/` | one conversation: adapter, session hook, reconcile after a lost stream, the scripted transport for stories |
16
+ | `composer/` | what the widget's composer lacks, mounted inside it (`ComposerBehaviour`): dictation keys, caret, height, the Help list |
17
+ | `messages/` | the rows around messages: copy footer, the reconnecting/stalled line |
18
+ | `shell/` | where the chat lives: column frame, dock/sheet, floating button with its hint, locked view, open/width state |
19
+ | `i18n/` | the assistant's own words, 17 languages bundled (`locales/`), `useAssistantCopy()`; the language comes from `@djangocfg/i18n`'s provider, the same one the widget's own copy follows |
20
+ | `styles/` | widget stylesheet entry, layout, composer and button motion |
21
+
22
+ `Host.tsx` (exported as `./host`) keeps one mounted chat for many surfaces; `AssistantChat.tsx` composes the widget.
23
+
24
+ ## Behaviour worth knowing
25
+
26
+ - **Dictation keys.** `⌘D` / `Ctrl+D` starts and stops (`formatHotkey` draws the right one for the system). While dictating, Enter stops and sends, Escape stops and keeps the text, from anywhere. Every stop waits with the microphone open until the field stops changing (450 ms of quiet, 2.5 s at most): the engine reports words late, and the widget's stop closes it and drops them. The engine is our own (`composer/wholeTranscript.ts`): it rebuilds the whole transcript from the browser's results at every event and restarts itself when the browser ends a run, because the widget's stitching of separate results kept only the last words.
27
+ - **Field.** While dictating its height only grows (no jumping between bursts). Pressing the empty strip around the composer focuses the field (keyboard devices only).
28
+ - **Caret.** Opening the panel on a keyboard device focuses the field; after a send the caret returns to the field, or to the microphone when the send ended a dictation (Enter then starts the next one). Never on phones.
29
+ - **Floating button.** Pops in, shows a message-style hint after a moment (`hint` prop; text defaults to the bundled one), then a ring every so often. No close control; the host decides how often to show it.
30
+ - **Help.** A quiet link under the composer opens the list of keys (`ComposerHelp`).
31
+
32
+ ## Tests
33
+
34
+ `pnpm --filter @djangocfg/widget-assistant check test`: the wire, stream and boundary tests, and `tsc --noEmit`.
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-assistant",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "keywords": [
6
+ "chat",
7
+ "assistant",
8
+ "sse",
9
+ "dictation",
10
+ "react",
11
+ "typescript"
12
+ ],
13
+ "author": {
14
+ "name": "DjangoCFG",
15
+ "url": "https://djangocfg.com"
16
+ },
17
+ "homepage": "https://djangocfg.com",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/markolofsen/django-cfg.git",
21
+ "directory": "widgets/assistant"
22
+ },
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "main": "./src/index.ts",
26
+ "types": "./src/index.ts",
27
+ "exports": {
28
+ ".": "./src/index.ts",
29
+ "./host": "./src/Host.tsx",
30
+ "./styles": "./src/styles/index.css"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "!src/**/*.test.ts",
35
+ "!src/**/*.test.tsx",
36
+ "!src/**/*.stories.tsx",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "check": "tsc --noEmit",
42
+ "check-types": "tsc --noEmit",
43
+ "lint": "eslint .",
44
+ "test": "vitest run"
45
+ },
46
+ "dependencies": {
47
+ "@djangocfg/widget-chat": "^0.1.14",
48
+ "zustand": "^5.0.14"
49
+ },
50
+ "peerDependencies": {
51
+ "@djangocfg/i18n": "^2.1.585",
52
+ "@djangocfg/ui-core": "^2.1.585",
53
+ "lucide-react": "^0.545.0",
54
+ "react": "^19.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@djangocfg/i18n": "^2.1.585",
58
+ "@djangocfg/typescript-config": "^2.1.585",
59
+ "@djangocfg/ui-core": "^2.1.585",
60
+ "@storybook/react-vite": "^10.5.0",
61
+ "@types/node": "^25.9.5",
62
+ "@types/react": "19.2.15",
63
+ "@types/react-dom": "19.2.3",
64
+ "lucide-react": "0.545.0",
65
+ "react": "19.2.7",
66
+ "react-dom": "19.2.7",
67
+ "storybook": "^10.5.0",
68
+ "typescript": "^5.9.3",
69
+ "vitest": "^4.1.10"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public"
73
+ }
74
+ }
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useRef, type ReactNode } from 'react';
4
+
5
+ import {
6
+ Chat,
7
+ ChatTranscript,
8
+ DataRenderer,
9
+ type ChatAdapter,
10
+ type ChatCapabilities,
11
+ type ChatMessage,
12
+ type PartRenderProps,
13
+ } from '@djangocfg/widget-chat';
14
+ import { Response } from '@djangocfg/widget-chat/content';
15
+
16
+ import type { SessionNotice } from './stream/useSession';
17
+ import { createWholeTranscriptSpeech } from './composer/wholeTranscript';
18
+ import { AssistantComposer } from './composer/AssistantComposer';
19
+ import { ComposerHelp } from './composer/ComposerHelp';
20
+ import { MessageFooter } from './messages/MessageFooter';
21
+ import { NoticeLine } from './messages/NoticeLine';
22
+
23
+ /** Draws one `data` part of a known `kind`. Return `undefined` to hand the part to the widget's own renderer. */
24
+ export type DataPartRenderer = (data: unknown, props: PartRenderProps) => ReactNode | undefined;
25
+
26
+ /** The dictation language the composer offers a picker for: a BCP-47 tag the host remembers. */
27
+ export interface SpeechLanguage {
28
+ value: string;
29
+ onChange: (language: string) => void;
30
+ }
31
+
32
+ interface Props {
33
+ adapter: ChatAdapter;
34
+ /** Example prompts shown in an empty chat. */
35
+ suggestions: string[];
36
+ /** The room on screen: `id` changes when the thread is reloaded (reconcile, clear), which reseeds the transcript. */
37
+ conversation?: { id?: string; history: readonly ChatMessage[] };
38
+ /** The host's drawing of structured parts, by `kind` (for example `film_cards`). Unknown kinds fall through to the widget. */
39
+ dataRenderers?: Readonly<Record<string, DataPartRenderer>>;
40
+ /** Dictation language and its picker (the microphone menu). Absent: the browser's default engine with the widget's default language. */
41
+ speech?: SpeechLanguage;
42
+ /** A plain line above the composer: reconnecting, or a stalled run with its Retry (`onRetry`). */
43
+ notice?: SessionNotice | null;
44
+ onRetry?: () => void;
45
+ /** Raise to put the caret in the field (the panel was just opened on a device with a keyboard). Not applied on first render. */
46
+ focusWhen?: boolean;
47
+ /** A keyboard device: after a send the caret goes back to the field (to the microphone when the send ended a dictation). */
48
+ refocus?: boolean;
49
+ className?: string;
50
+ }
51
+
52
+ const EMPTY_ROOM = { history: [] } as const;
53
+ const NO_RENDERERS: Readonly<Record<string, DataPartRenderer>> = {};
54
+
55
+ /**
56
+ * The chat: the framework widget with markdown text, the host's structured parts, message actions, and the full composer (dictation with its language menu,
57
+ * 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
58
+ * is composed here from its own primitives: every behaviour is the widget's, nothing is re-implemented.
59
+ */
60
+ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, dataRenderers = NO_RENDERERS, speech, notice, onRetry, focusWhen, refocus, className }: Props) {
61
+ const renderers = useMemo(
62
+ () => ({
63
+ text: ({ part }: PartRenderProps) => (part.type === 'text' ? <Response>{part.text}</Response> : null),
64
+ data: (props: PartRenderProps) => {
65
+ const { part } = props;
66
+ if (part.type === 'data') {
67
+ const drawn = dataRenderers[part.kind]?.(part.data, props);
68
+ if (drawn !== undefined) return drawn;
69
+ }
70
+ return DataRenderer(props);
71
+ },
72
+ }),
73
+ [dataRenderers],
74
+ );
75
+ // The language is read at each start, through a ref: the capability must not be rebuilt when the picker changes.
76
+ const language = useRef(speech?.value);
77
+ language.current = speech?.value;
78
+ const voice = useMemo(() => createWholeTranscriptSpeech(() => language.current ?? (typeof navigator === 'undefined' ? 'en-US' : navigator.language)), []);
79
+ const capabilities = useMemo<ChatCapabilities>(
80
+ () => ({ suggestions: { items: suggestions.map((prompt, index) => ({ id: `s${index}`, title: prompt, prompt })) }, ...(voice ? { voice } : {}) }),
81
+ [suggestions, voice],
82
+ );
83
+ const slots = useMemo(
84
+ () => ({
85
+ // Progress while a run streams is the widget's own pending row inside the transcript; a second line under the composer repeated it and
86
+ // moved the input. Only a rare notice (reconnecting, stalled) uses this slot.
87
+ footer: (
88
+ <>
89
+ {notice ? <NoticeLine notice={notice} onRetry={onRetry} /> : null}
90
+ <ComposerHelp />
91
+ </>
92
+ ),
93
+ }),
94
+ [notice, onRetry],
95
+ );
96
+ return (
97
+ <Chat
98
+ adapter={adapter}
99
+ capabilities={capabilities}
100
+ renderers={renderers}
101
+ conversation={conversation}
102
+ slots={slots}
103
+ className={`assistant-chat flex h-full min-h-0 flex-col ${className ?? ''}`}
104
+ body={{
105
+ kind: 'custom',
106
+ children: (
107
+ <>
108
+ <ChatTranscript footer={(message) => <MessageFooter message={message} />} />
109
+ <AssistantComposer speech={speech} draftRoom={conversation.id ?? 'assistant'} focusWhen={focusWhen} refocus={refocus} />
110
+ </>
111
+ ),
112
+ }}
113
+ />
114
+ );
115
+ }
package/src/Host.tsx ADDED
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ /**
7
+ * One mounted chat, many places. The chat is rendered through a portal into one detached element (`AssistantPortal`); a surface (`AssistantSlot`) attaches that
8
+ * element where it is visible and parks it when it unmounts. Navigating or switching surface while a run streams therefore never unmounts the chat:
9
+ * nothing is aborted and the thread state is not rebuilt. `AssistantHostProvider` wraps the page tree once, in the layout.
10
+ */
11
+ interface HostValue {
12
+ element: HTMLElement;
13
+ park: HTMLElement;
14
+ }
15
+ const HostContext = createContext<HostValue | null>(null);
16
+
17
+ export function AssistantHostProvider({ children }: { children: ReactNode }) {
18
+ // Browser only: on the server there is no element, and slots render empty.
19
+ const value = useMemo<HostValue | null>(() => {
20
+ if (typeof document === 'undefined') return null;
21
+ const element = document.createElement('div');
22
+ element.className = 'h-full min-h-0';
23
+ const park = document.createElement('div');
24
+ park.hidden = true;
25
+ park.appendChild(element);
26
+ return { element, park };
27
+ }, []);
28
+
29
+ useEffect(() => {
30
+ if (!value) return;
31
+ document.body.appendChild(value.park);
32
+ return () => value.park.remove();
33
+ }, [value]);
34
+
35
+ return <HostContext.Provider value={value}>{children}</HostContext.Provider>;
36
+ }
37
+
38
+ /** Renders the chat (`children`) into the host element. Mount it once, for a signed-in viewer, inside the provider. */
39
+ export function AssistantPortal({ children }: { children: ReactNode }) {
40
+ const host = useContext(HostContext);
41
+ return host ? createPortal(children, host.element) : null;
42
+ }
43
+
44
+ /** A place that shows the chat: an empty box the chat element is moved into while the slot is mounted. */
45
+ export function AssistantSlot({ className }: { className?: string }) {
46
+ const host = useContext(HostContext);
47
+ const box = useRef<HTMLDivElement>(null);
48
+ useLayoutEffect(() => {
49
+ if (!host || !box.current) return;
50
+ const place = box.current;
51
+ place.appendChild(host.element);
52
+ return () => {
53
+ if (host.element.parentElement === place) host.park.appendChild(host.element);
54
+ };
55
+ }, [host]);
56
+ return <div ref={box} className={className} />;
57
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { ChatComposer } from '@djangocfg/widget-chat';
4
+
5
+ import { ComposerBehaviour } from './ComposerBehaviour';
6
+ import { useSentPrompts } from './useSentPrompts';
7
+
8
+ interface Props {
9
+ speech?: { value: string; onChange: (language: string) => void };
10
+ draftRoom: string;
11
+ focusWhen?: boolean;
12
+ refocus?: boolean;
13
+ }
14
+
15
+ /** The widget's composer with its recall fed from the live transcript and our behaviour mounted inside. Must sit inside the chat. */
16
+ export function AssistantComposer({ speech, draftRoom, focusWhen, refocus }: Props) {
17
+ const history = useSentPrompts();
18
+ return <ChatComposer history={history} speechLanguage={speech} draftRoom={draftRoom} toolbarEnd={<ComposerBehaviour focusWhen={focusWhen} refocus={refocus} />} />;
19
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+
5
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
6
+ import { useComposer } from '@djangocfg/widget-chat';
7
+
8
+ import { useCaretFocus } from './useCaretFocus';
9
+ import { useComposerNodes } from './useComposerNodes';
10
+ import { DICTATION_TOGGLE } from './keys';
11
+ import { useFocusArea } from './useFocusArea';
12
+ import { useGracefulStop } from './useGracefulStop';
13
+ import { useHeightFloor } from './useHeightFloor';
14
+ import { useRecording } from './useRecording';
15
+
16
+ interface Props {
17
+ focusWhen?: boolean;
18
+ refocus?: boolean;
19
+ }
20
+
21
+ /**
22
+ * What the widget's composer lacks, mounted inside it (the `toolbarEnd` slot, the only place the draft's `send` is reachable). Renders nothing.
23
+ *
24
+ * While dictation is on, Enter and Escape mean the same wherever the focus is: pressing the microphone leaves the focus on that button, so Enter re-pressed it and
25
+ * merely stopped the recording, and the field's own Enter handling works only with the caret in the field. Enter stops the recording and sends what was heard;
26
+ * Escape stops it and keeps the text. The field's height and the caret are handled by the hooks beside this file.
27
+ */
28
+ export function ComposerBehaviour({ focusWhen = false, refocus = false }: Props) {
29
+ const marker = useRef<HTMLSpanElement>(null);
30
+ const nodes = useComposerNodes(marker);
31
+ const { field } = nodes;
32
+ const composer = useComposer();
33
+ const latest = useRef(composer);
34
+ latest.current = composer;
35
+
36
+ const recording = useRecording(nodes);
37
+ useHeightFloor(nodes, recording);
38
+ useFocusArea(nodes, marker, refocus);
39
+ useCaretFocus(nodes, { focusWhen, refocus, recording, onSend: composer.onSend });
40
+
41
+ // Waits for the words still being recognised, then commits them into the draft; sending reads the draft after that.
42
+ const stop = useGracefulStop(nodes, recording, useCallback(() => latest.current.draft.text, []));
43
+
44
+ const sendWhenStopped = useCallback(() => {
45
+ stop(() => {
46
+ if (latest.current.canSend) latest.current.send();
47
+ });
48
+ }, [stop]);
49
+
50
+ // In the field the widget's own Enter would send at once and cut the dictation; this capture listener takes the key first, so the send waits like the stop does.
51
+ useEffect(() => {
52
+ const node = field();
53
+ if (!recording || !node) return;
54
+ const take = (event: KeyboardEvent) => {
55
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || event.isComposing) return;
56
+ event.preventDefault();
57
+ event.stopImmediatePropagation();
58
+ sendWhenStopped();
59
+ };
60
+ node.addEventListener('keydown', take, true);
61
+ return () => node.removeEventListener('keydown', take, true);
62
+ }, [recording, field, sendWhenStopped]);
63
+
64
+ // Elsewhere (the focus stayed on the microphone button) the same Enter comes through the hotkey.
65
+ useHotkey(
66
+ 'enter',
67
+ (event) => {
68
+ if (event.target instanceof HTMLTextAreaElement) return;
69
+ sendWhenStopped();
70
+ },
71
+ { enabled: recording, inInput: true, preventDefault: true },
72
+ );
73
+ // One key to start and to stop, wherever the focus is. Stopping waits like every other stop; starting is the microphone's own press.
74
+ useHotkey(DICTATION_TOGGLE, () => (recording ? stop() : nodes.mic()?.click()), { inInput: true, preventDefault: true });
75
+ useHotkey('escape', () => stop(), { enabled: recording, inInput: true, preventDefault: true });
76
+
77
+ // Assistive technology and password managers learn the key from the button itself.
78
+ const { mic } = nodes;
79
+ useEffect(() => {
80
+ mic()?.setAttribute('aria-keyshortcuts', 'Meta+D Control+D');
81
+ }, [mic, recording]);
82
+
83
+ return <span ref={marker} hidden />;
84
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { Kbd, Popover, PopoverContent, PopoverTrigger } from '@djangocfg/ui-core/components';
4
+ import { formatHotkey } from '@djangocfg/ui-core/hooks';
5
+
6
+ import { useAssistantCopy } from '../i18n';
7
+ import { DICTATION_TOGGLE } from './keys';
8
+
9
+ /** The keys the composer answers to, in the order a person needs them. `combo` is a hotkey string, drawn for the viewer's system (⌘ on Apple, Ctrl elsewhere). */
10
+ function useRows() {
11
+ const copy = useAssistantCopy();
12
+ return [
13
+ { combo: DICTATION_TOGGLE, text: copy.helpDictate },
14
+ { combo: 'enter', text: copy.helpSend },
15
+ { combo: 'escape', text: copy.helpStop },
16
+ { combo: 'arrowup', text: copy.helpRecall },
17
+ { combo: 'shift+enter', text: copy.helpNewline },
18
+ ];
19
+ }
20
+
21
+ /** A quiet "Help" under the composer; pressing it opens the short list of keys. */
22
+ export function ComposerHelp() {
23
+ const copy = useAssistantCopy();
24
+ const rows = useRows();
25
+ return (
26
+ <div className="px-4 pb-1 text-end">
27
+ <Popover>
28
+ <PopoverTrigger asChild>
29
+ <button type="button" className="rounded px-1 text-xs text-muted-foreground underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring">
30
+ {copy.helpLabel}
31
+ </button>
32
+ </PopoverTrigger>
33
+ <PopoverContent side="top" align="end" className="w-72 p-3">
34
+ <ul className="space-y-2">
35
+ {rows.map(({ combo, text }) => (
36
+ <li key={combo} className="flex items-start justify-between gap-3 text-sm">
37
+ <span className="text-muted-foreground">{text}</span>
38
+ <Kbd className="shrink-0">{formatHotkey(combo)}</Kbd>
39
+ </li>
40
+ ))}
41
+ </ul>
42
+ </PopoverContent>
43
+ </Popover>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,2 @@
1
+ /** Starts or stops dictation from anywhere in the composer (`mod` is ⌘ on Apple systems and Ctrl elsewhere). */
2
+ export const DICTATION_TOGGLE = 'mod+d';
@@ -0,0 +1,49 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import type { ComposerNodes } from './useComposerNodes';
4
+
5
+ interface Options {
6
+ /** Each time this turns true the caret goes to the field (the panel was opened on a device with a keyboard); never on first render. */
7
+ focusWhen: boolean;
8
+ /** A keyboard device: after a send the caret returns to the field, or to the microphone when the send ended a dictation (so Enter starts the next one). */
9
+ refocus: boolean;
10
+ recording: boolean;
11
+ /** The draft's send listener registry. */
12
+ onSend: (listener: () => void) => () => void;
13
+ }
14
+
15
+ /** Where the caret is after the panel opens and after a send. Both are for keyboards only: on a phone either would raise the keyboard over the answer. */
16
+ export function useCaretFocus({ mic, field }: ComposerNodes, { focusWhen, refocus, recording, onSend }: Options): void {
17
+ const wasOn = useRef(focusWhen);
18
+ useEffect(() => {
19
+ if (focusWhen && !wasOn.current) {
20
+ const node = field();
21
+ node?.focus();
22
+ node?.setSelectionRange(node.value.length, node.value.length);
23
+ }
24
+ wasOn.current = focusWhen;
25
+ }, [focusWhen, field]);
26
+
27
+ // Read through a ref because the send listener runs before the widget ends the dictation.
28
+ const dictating = useRef(false);
29
+ useEffect(() => {
30
+ if (recording) {
31
+ dictating.current = true;
32
+ return;
33
+ }
34
+ // A send that follows the stop (Enter outside the field waits 60 ms) still counts as ending a dictation; a later one does not.
35
+ const id = window.setTimeout(() => {
36
+ dictating.current = false;
37
+ }, 400);
38
+ return () => window.clearTimeout(id);
39
+ }, [recording]);
40
+
41
+ useEffect(() => {
42
+ if (!refocus) return;
43
+ return onSend(() => {
44
+ const next = dictating.current ? mic() : field();
45
+ dictating.current = false;
46
+ window.setTimeout(() => next?.focus(), 0);
47
+ });
48
+ }, [refocus, onSend, mic, field]);
49
+ }
@@ -0,0 +1,14 @@
1
+ import { useCallback, type RefObject } from 'react';
2
+
3
+ /**
4
+ * The composer's own elements, found from a marker rendered inside it. The widget keeps the field and the microphone private, so the DOM is the only route; the
5
+ * selectors are the widget's classes (`composer-box`, `composer-mic`), which its stylesheet also relies on.
6
+ */
7
+ export function useComposerNodes(marker: RefObject<HTMLElement | null>) {
8
+ const box = useCallback(() => marker.current?.closest('.composer-box') ?? null, [marker]);
9
+ const mic = useCallback(() => box()?.querySelector<HTMLButtonElement>('.composer-mic') ?? null, [box]);
10
+ const field = useCallback(() => box()?.querySelector<HTMLTextAreaElement>('textarea') ?? null, [box]);
11
+ return { mic, field };
12
+ }
13
+
14
+ export type ComposerNodes = ReturnType<typeof useComposerNodes>;
@@ -0,0 +1,29 @@
1
+ import { useEffect } from 'react';
2
+
3
+ import type { ComposerNodes } from './useComposerNodes';
4
+
5
+ const INTERACTIVE = 'button, a, input, textarea, select, [role="button"], [role="dialog"], [data-radix-popper-content-wrapper]';
6
+
7
+ /**
8
+ * A press on the empty space around the composer (its column, and the padding of the chat under the transcript) puts the caret in the field, so the target is the whole
9
+ * strip and not only the box. A press on a control, or anywhere in the transcript, is left alone. Keyboard devices only (`enabled`): on a phone it raises the keyboard.
10
+ */
11
+ export function useFocusArea({ field }: ComposerNodes, marker: { current: HTMLElement | null }, enabled: boolean): void {
12
+ useEffect(() => {
13
+ const column = marker.current?.closest<HTMLElement>('.chat-column');
14
+ const root = marker.current?.closest<HTMLElement>('.assistant-chat');
15
+ if (!enabled || !column || !root) return;
16
+ const take = (event: MouseEvent) => {
17
+ const target = event.target as HTMLElement;
18
+ // The root itself is its padding strip; inside it only the composer column counts, never the transcript.
19
+ const inStrip = column.contains(target) || target === root;
20
+ if (!inStrip || target.closest(INTERACTIVE)) return;
21
+ event.preventDefault();
22
+ const node = field();
23
+ node?.focus();
24
+ node?.setSelectionRange(node.value.length, node.value.length);
25
+ };
26
+ root.addEventListener('mousedown', take);
27
+ return () => root.removeEventListener('mousedown', take);
28
+ }, [enabled, field, marker]);
29
+ }
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ import type { ComposerNodes } from './useComposerNodes';
4
+
5
+ /**
6
+ * The microphone stays open after a stop is asked for, until the words stop arriving. Recognition runs behind the voice: the engine reports a phrase a few hundred
7
+ * milliseconds after it was said, and the widget's stop closes the engine at once and drops what is still on its way. So the stop waits for the field to stay unchanged for
8
+ * `QUIET_MS` (but at least that long, and never past `PATIENCE_MS`); the stop is then the widget's own, and its commit puts what was heard into the draft.
9
+ */
10
+ export const QUIET_MS = 450;
11
+ export const PATIENCE_MS = 2500;
12
+ const POLL_MS = 80;
13
+
14
+ /**
15
+ * `stop(after)` ends the dictation after the grace period, then runs `after` once the transcript has reached the draft. The microphone button's own press while
16
+ * recording goes through the same wait (a capture listener takes the press first and re-issues it later), so every way to stop loses nothing.
17
+ */
18
+ export function useGracefulStop({ mic }: ComposerNodes, recording: boolean, draftText: () => string): (after?: () => void) => void {
19
+ const waiting = useRef(false);
20
+ const ours = useRef(false);
21
+
22
+ const stop = useCallback(
23
+ (after?: () => void) => {
24
+ if (waiting.current) return;
25
+ waiting.current = true;
26
+ const began = Date.now();
27
+ let seen = draftText();
28
+ let changed = began;
29
+ const settle = () => {
30
+ const now = Date.now();
31
+ const text = draftText();
32
+ if (text !== seen) {
33
+ seen = text;
34
+ changed = now;
35
+ }
36
+ if (now - changed < QUIET_MS && now - began < PATIENCE_MS) return void window.setTimeout(settle, POLL_MS);
37
+ ours.current = true;
38
+ mic()?.click();
39
+ ours.current = false;
40
+ waiting.current = false;
41
+ // The widget commits the transcript on that click; the draft holds it one render later.
42
+ if (after) window.setTimeout(after, 80);
43
+ };
44
+ window.setTimeout(settle, POLL_MS);
45
+ },
46
+ [mic, draftText],
47
+ );
48
+
49
+ useEffect(() => {
50
+ const button = mic();
51
+ if (!recording || !button) return;
52
+ const take = (event: Event) => {
53
+ if (ours.current) return;
54
+ event.preventDefault();
55
+ event.stopImmediatePropagation();
56
+ stop();
57
+ };
58
+ button.addEventListener('click', take, true);
59
+ return () => button.removeEventListener('click', take, true);
60
+ }, [recording, mic, stop]);
61
+
62
+ return stop;
63
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect } from 'react';
2
+
3
+ import type { ComposerNodes } from './useComposerNodes';
4
+
5
+ /**
6
+ * While `active`, the field may only grow. Dictation arrives in bursts and the engine revises its guess, so the height would otherwise go up and down with each one:
7
+ * the tallest height seen is kept as a floor, and the floor goes when `active` ends.
8
+ */
9
+ export function useHeightFloor({ field }: ComposerNodes, active: boolean): void {
10
+ useEffect(() => {
11
+ const node = field();
12
+ if (!active || !node) return;
13
+ let tallest = 0;
14
+ const watch = new ResizeObserver(() => {
15
+ const height = node.offsetHeight;
16
+ if (height > tallest) {
17
+ tallest = height;
18
+ node.style.minHeight = `${height}px`;
19
+ }
20
+ });
21
+ watch.observe(node);
22
+ return () => {
23
+ watch.disconnect();
24
+ node.style.minHeight = '';
25
+ };
26
+ }, [active, field]);
27
+ }