@djangocfg/widget-chat 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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- package/src/widgets/media.tsx +239 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useRef,
|
|
4
|
+
type ChangeEvent,
|
|
5
|
+
type ClipboardEvent,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
import { FIELD_SIZING, useAutosize } from "./use-autosize";
|
|
12
|
+
import { useComposer } from "../context";
|
|
13
|
+
|
|
14
|
+
export interface ComposerInputProps {
|
|
15
|
+
readonly placeholder?: string;
|
|
16
|
+
/** The accessible name. Copy is the host's — this package carries no locales. */
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly className?: string;
|
|
19
|
+
/** Rows to grow to before scrolling. */
|
|
20
|
+
readonly maxRows?: number;
|
|
21
|
+
/**
|
|
22
|
+
* The textarea node, for a host that must reach it.
|
|
23
|
+
*
|
|
24
|
+
* Exists for the two things only the element can do: `focus()`, and placing
|
|
25
|
+
* the CARET. History recall restores a caret position along with the text,
|
|
26
|
+
* and there is no ref-free way to call `setSelectionRange`.
|
|
27
|
+
*
|
|
28
|
+
* A ref rather than an `onFocus`-style prop: it is React's own escape hatch
|
|
29
|
+
* for "I need the element", it adds no behaviour here, and it cannot grow
|
|
30
|
+
* into a second way of driving the input.
|
|
31
|
+
*/
|
|
32
|
+
readonly ref?: Ref<HTMLTextAreaElement>;
|
|
33
|
+
/**
|
|
34
|
+
* Runs BEFORE this component's own Enter handling, and can veto it with
|
|
35
|
+
* `preventDefault()`.
|
|
36
|
+
*
|
|
37
|
+
* The seam anything claiming a key needs — recall claims the arrows, and a
|
|
38
|
+
* host popup over the textarea would claim Enter and Escape.
|
|
39
|
+
*
|
|
40
|
+
* The protocol is `defaultPrevented`, not an `isClaimed` prop, and two
|
|
41
|
+
* references independently landed on it: Vercel's `prompt-input.tsx` calls
|
|
42
|
+
* the external handler first and returns early if it prevented default, and
|
|
43
|
+
* the archive's `ComposerRichTextarea.tsx:172` does the same. It is the
|
|
44
|
+
* better shape because the veto travels with the event — a boolean prop would
|
|
45
|
+
* be a second source of truth, updated one render later than the keystroke
|
|
46
|
+
* that needs it.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately the only escape hatch, and never a general `...props` spread:
|
|
49
|
+
* a spread would let a host quietly re-bind `onChange`, `value` or `onPaste`
|
|
50
|
+
* and take ownership of state this component holds, which is the exact door
|
|
51
|
+
* the 83-prop predecessor left open. A host building a menu here also needs
|
|
52
|
+
* the caret and an `aria-*` channel; both existed for the mention menu and
|
|
53
|
+
* left with it, and should come back together or not at all.
|
|
54
|
+
*/
|
|
55
|
+
readonly onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rows to grow to before the box scrolls internally.
|
|
60
|
+
*
|
|
61
|
+
* Eight, matching both references. Twelve turned a long paste into a wall that
|
|
62
|
+
* pushed the transcript off screen — the composer took the room the answer
|
|
63
|
+
* needs, which is the wrong trade for a surface whose job is reading replies.
|
|
64
|
+
*/
|
|
65
|
+
const DEFAULT_MAX_ROWS = 8;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The text area.
|
|
69
|
+
*
|
|
70
|
+
* The KEYBOARD and the clipboard: Enter-to-send, IME composition, and the
|
|
71
|
+
* paste rule. They belong together because they all reason about the same
|
|
72
|
+
* thing — what a keystroke means in this field — and they all need the same
|
|
73
|
+
* ref.
|
|
74
|
+
*
|
|
75
|
+
* Autosize used to live here too and does not any more (`use-autosize.ts`). It
|
|
76
|
+
* shares the node and nothing else: it reasons about PIXELS, carries its own
|
|
77
|
+
* feature-detection constant, and reading it while looking for a key handler
|
|
78
|
+
* was pure noise. The file was over its 300-line budget, and the budget's own
|
|
79
|
+
* comment names the wrong fix — raising the limit — so it was split instead.
|
|
80
|
+
*/
|
|
81
|
+
export function ComposerInput({
|
|
82
|
+
placeholder,
|
|
83
|
+
label,
|
|
84
|
+
className,
|
|
85
|
+
maxRows = DEFAULT_MAX_ROWS,
|
|
86
|
+
ref: externalRef,
|
|
87
|
+
onKeyDown: hostKeyDown,
|
|
88
|
+
}: ComposerInputProps) {
|
|
89
|
+
const { draft, setText, handlePaste, send, canSend } = useComposer();
|
|
90
|
+
const ref = useRef<HTMLTextAreaElement | null>(null);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fill the internal ref (autosize measures through it) and the host's, if
|
|
94
|
+
* one was passed. A callback rather than handing the host `ref` directly:
|
|
95
|
+
* the two must both see the node, and a single `ref` prop can only serve one.
|
|
96
|
+
*/
|
|
97
|
+
const attach = useCallback(
|
|
98
|
+
(node: HTMLTextAreaElement | null) => {
|
|
99
|
+
ref.current = node;
|
|
100
|
+
if (typeof externalRef === "function") externalRef(node);
|
|
101
|
+
else if (externalRef) externalRef.current = node;
|
|
102
|
+
},
|
|
103
|
+
[externalRef],
|
|
104
|
+
);
|
|
105
|
+
/**
|
|
106
|
+
* True between `compositionstart` and `compositionend`. A CJK/IME candidate
|
|
107
|
+
* window uses Enter to CONFIRM the candidate, and that Enter also reaches
|
|
108
|
+
* keydown — sending there truncates the word being composed and is the classic
|
|
109
|
+
* "my Japanese input sends half a sentence" bug. `event.isComposing` covers
|
|
110
|
+
* most browsers, but Safari has historically cleared it on the confirming
|
|
111
|
+
* keystroke, so we track the events too and treat either signal as composing.
|
|
112
|
+
*/
|
|
113
|
+
const composing = useRef(false);
|
|
114
|
+
|
|
115
|
+
const multiline = useAutosize(ref, draft.text, maxRows);
|
|
116
|
+
|
|
117
|
+
const onChange = useCallback(
|
|
118
|
+
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
119
|
+
setText(event.target.value);
|
|
120
|
+
},
|
|
121
|
+
[setText],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const onPaste = useCallback(
|
|
125
|
+
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
126
|
+
// The hook owns `preventDefault` and returns whether it consumed the
|
|
127
|
+
// event; it must NOT be called again or re-implemented here. Long text
|
|
128
|
+
// landing in both the box and a chip is the failure this prevents.
|
|
129
|
+
handlePaste(event);
|
|
130
|
+
},
|
|
131
|
+
[handlePaste],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const onKeyDown = useCallback(
|
|
135
|
+
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
136
|
+
// The host first, then its veto. This is the ONLY way a surface outside
|
|
137
|
+
// this component can claim a key: it renders elsewhere and focus never
|
|
138
|
+
// leaves the textarea. Recall claims the arrows this way.
|
|
139
|
+
hostKeyDown?.(event);
|
|
140
|
+
if (event.defaultPrevented) return;
|
|
141
|
+
|
|
142
|
+
if (event.key !== "Enter") return;
|
|
143
|
+
if (event.shiftKey) return;
|
|
144
|
+
if (composing.current || event.nativeEvent.isComposing) return;
|
|
145
|
+
// Modifier chords belong to the OS/host (newline on some platforms), and
|
|
146
|
+
// claiming them here would make the composer disagree with the textarea.
|
|
147
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
148
|
+
// Before the guard: a plain Enter must never insert a newline on the send
|
|
149
|
+
// path, including when the draft is empty and nothing is sent.
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
if (!canSend) return;
|
|
152
|
+
send();
|
|
153
|
+
},
|
|
154
|
+
[canSend, send, hostKeyDown],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const onCompositionStart = useCallback(() => {
|
|
158
|
+
composing.current = true;
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const onCompositionEnd = useCallback(() => {
|
|
162
|
+
composing.current = false;
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<textarea
|
|
167
|
+
ref={attach}
|
|
168
|
+
className={className}
|
|
169
|
+
value={draft.text}
|
|
170
|
+
placeholder={placeholder}
|
|
171
|
+
aria-label={label}
|
|
172
|
+
rows={1}
|
|
173
|
+
/*
|
|
174
|
+
Has the draft outgrown ONE line?
|
|
175
|
+
|
|
176
|
+
As DATA, because CSS cannot ask it: the answer decides whether a SIBLING
|
|
177
|
+
(the control row) keeps sharing this row, and no container query or
|
|
178
|
+
`:has()` can read another element's measured height. A host styles
|
|
179
|
+
`[data-multiline]`; the package chooses no layout.
|
|
180
|
+
|
|
181
|
+
MEASURED, not `text.includes("\\n")`. A single long line that SOFT-WRAPS
|
|
182
|
+
has no newline in it and is the common case — it is what a user types.
|
|
183
|
+
`useAutosize` already knows the height, so the flag comes from there
|
|
184
|
+
rather than from a second measurement that could disagree with it.
|
|
185
|
+
*/
|
|
186
|
+
data-multiline={multiline || undefined}
|
|
187
|
+
/*
|
|
188
|
+
* Autosize, declared rather than measured — where the engine can.
|
|
189
|
+
*
|
|
190
|
+
* Inline rather than a class because this package ships no CSS and
|
|
191
|
+
* `className` belongs to the host: a host that styles the field must not
|
|
192
|
+
* have to know that the cap is `maxRows`, and the two numbers cannot be
|
|
193
|
+
* allowed to drift apart. `lh` is the row unit, so the cap stays in rows
|
|
194
|
+
* whatever the host's line-height is.
|
|
195
|
+
*
|
|
196
|
+
* Empty on browsers without support, where the effect above sizes the box
|
|
197
|
+
* instead. Never both — a written `height` would override this.
|
|
198
|
+
*/
|
|
199
|
+
// `fieldSizing` is newer than the pinned csstype, so the cast is the
|
|
200
|
+
// property being real and the types being behind — not a shape being
|
|
201
|
+
// forced. Remove it once csstype carries the property.
|
|
202
|
+
style={
|
|
203
|
+
FIELD_SIZING
|
|
204
|
+
? ({ fieldSizing: "content", maxHeight: `${maxRows}lh` } as CSSProperties)
|
|
205
|
+
: undefined
|
|
206
|
+
}
|
|
207
|
+
onChange={onChange}
|
|
208
|
+
onPaste={onPaste}
|
|
209
|
+
onKeyDown={onKeyDown}
|
|
210
|
+
onCompositionStart={onCompositionStart}
|
|
211
|
+
onCompositionEnd={onCompositionEnd}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ComposerKeysOptions {
|
|
4
|
+
readonly recall: {
|
|
5
|
+
reset(): void;
|
|
6
|
+
onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void;
|
|
7
|
+
};
|
|
8
|
+
readonly draftText: string;
|
|
9
|
+
/**
|
|
10
|
+
* Enter is about to send the draft.
|
|
11
|
+
*
|
|
12
|
+
* The resolver decides WHICH key sends; it does not know what the send means
|
|
13
|
+
* to anyone else. The caller announces it.
|
|
14
|
+
*/
|
|
15
|
+
readonly onSending?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One keyboard, one claimant: recall.
|
|
20
|
+
*
|
|
21
|
+
* It stays a resolver rather than folding into the component because the order
|
|
22
|
+
* of the two things that happen on Enter is the whole subtlety, and it is
|
|
23
|
+
* testable here without a DOM. Two menus used to claim keys ahead of recall;
|
|
24
|
+
* both left with the product that supplied their vocabularies.
|
|
25
|
+
*/
|
|
26
|
+
export function composerKeyDown(
|
|
27
|
+
event: KeyboardEvent<HTMLTextAreaElement>,
|
|
28
|
+
options: ComposerKeysOptions,
|
|
29
|
+
): void {
|
|
30
|
+
const { recall, draftText, onSending } = options;
|
|
31
|
+
|
|
32
|
+
// Reset BEFORE the composer clears the draft: after it there is nothing left
|
|
33
|
+
// to record, and a ladder left mid-walk makes the next ArrowUp resume from
|
|
34
|
+
// wherever the last one stopped rather than from the newest prompt.
|
|
35
|
+
// `onSending` fires here, BEFORE the send, and that timing is the point: a
|
|
36
|
+
// transcript-derived ladder only moves when history is re-read, which is not
|
|
37
|
+
// until the turn lands, and ArrowUp must reach what was just sent before then.
|
|
38
|
+
// Announcing from the send path instead would reopen that gap.
|
|
39
|
+
if (event.key === "Enter" && !event.shiftKey && draftText.trim() !== "") {
|
|
40
|
+
onSending?.();
|
|
41
|
+
recall.reset();
|
|
42
|
+
}
|
|
43
|
+
recall.onKeyDown(event);
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Can the browser size a field to its own content?
|
|
5
|
+
*
|
|
6
|
+
* When it can, autosize is a CSS declaration and the measure-and-write cycle
|
|
7
|
+
* below is dead weight — worse than dead, since writing `height` in JS overrides
|
|
8
|
+
* what `field-sizing` computed. When it cannot (Firefox, Safari before 26) the
|
|
9
|
+
* effect is the only implementation, so it stays.
|
|
10
|
+
*
|
|
11
|
+
* Read ONCE at module scope, not per keystroke: it is a property of the engine
|
|
12
|
+
* and cannot change while the page is loaded. Guarded for SSR, where there is no
|
|
13
|
+
* `CSS` object at all and the JS path is the safe assumption.
|
|
14
|
+
*/
|
|
15
|
+
export const FIELD_SIZING =
|
|
16
|
+
typeof CSS !== "undefined" &&
|
|
17
|
+
typeof CSS.supports === "function" &&
|
|
18
|
+
CSS.supports("field-sizing", "content");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Grow a textarea with its content, up to `maxRows`, then scroll.
|
|
22
|
+
*
|
|
23
|
+
* Beside `input.tsx` rather than inside it: the two share the node and nothing
|
|
24
|
+
* else. `input.tsx` is a KEYBOARD component — paste, Enter-to-send and IME
|
|
25
|
+
* composition all reason about what a keystroke means — while this measures
|
|
26
|
+
* pixels and carries a module-scope feature-detection constant of its own.
|
|
27
|
+
*
|
|
28
|
+
* Runs as a layout effect, not an effect: the box must reach its final height
|
|
29
|
+
* in the same paint as the text, or every keystroke shows one frame at the old
|
|
30
|
+
* height. Keyed on the text so a PROGRAMMATIC change resizes too — sending
|
|
31
|
+
* clears the draft, "attach as text" fills it, and neither is a keystroke.
|
|
32
|
+
*/
|
|
33
|
+
export function useAutosize(
|
|
34
|
+
ref: RefObject<HTMLTextAreaElement | null>,
|
|
35
|
+
text: string,
|
|
36
|
+
maxRows: number,
|
|
37
|
+
): boolean {
|
|
38
|
+
// More than one line, INCLUDING a soft-wrapped long one — which
|
|
39
|
+
// `text.includes("\n")` cannot see and is what users actually type.
|
|
40
|
+
const [multiline, setMultiline] = useState(false);
|
|
41
|
+
// The field's width while the flag is OFF — the one width the wrap question
|
|
42
|
+
// may be asked at. Learned from the field itself rather than configured, so a
|
|
43
|
+
// host that lays the composer out differently needs no second number here.
|
|
44
|
+
const compactWidth = useRef<number | null>(null);
|
|
45
|
+
|
|
46
|
+
const resize = useCallback(() => {
|
|
47
|
+
const node = ref.current;
|
|
48
|
+
if (node === null) return;
|
|
49
|
+
|
|
50
|
+
// Multiline, measured. Before the `field-sizing` return: the flag is about
|
|
51
|
+
// rendered height either way, and after it this never runs on a modern
|
|
52
|
+
// browser.
|
|
53
|
+
//
|
|
54
|
+
// Asked as a ROW COUNT rather than as a height comparison. `scrollHeight`
|
|
55
|
+
// is an integer, so a fractional line height never divides evenly into it
|
|
56
|
+
// and any "taller than one line" test needs a tolerance — the sort of
|
|
57
|
+
// constant that is invisibly wrong on the one browser nobody checked.
|
|
58
|
+
// Rounding the quotient puts the tolerance where it belongs: half a row,
|
|
59
|
+
// whatever a row happens to measure.
|
|
60
|
+
//
|
|
61
|
+
// `scrollHeight` includes the field's own vertical padding, so the inset
|
|
62
|
+
// comes off first — without it EVERY field reports as multiline.
|
|
63
|
+
const probe = window.getComputedStyle(node);
|
|
64
|
+
const rowHeight = Number.parseFloat(probe.lineHeight);
|
|
65
|
+
if (!Number.isNaN(rowHeight) && rowHeight > 0) {
|
|
66
|
+
const inset =
|
|
67
|
+
Number.parseFloat(probe.paddingTop) + Number.parseFloat(probe.paddingBottom);
|
|
68
|
+
const rowsAt = () => Math.round((node.scrollHeight - inset) / rowHeight);
|
|
69
|
+
// Measured at the COMPACT width, always — never at the current one.
|
|
70
|
+
//
|
|
71
|
+
// The flag widens the field, so asking a wide field "do you wrap?" asks
|
|
72
|
+
// about a width the answer itself created: the text fits, the flag drops,
|
|
73
|
+
// the field narrows, the text wraps, the flag returns. Measured at 19
|
|
74
|
+
// flips over 36 keystrokes, one per character.
|
|
75
|
+
//
|
|
76
|
+
// `compactWidth` is the width the field has while the flag is off, so the
|
|
77
|
+
// question is always the stable one: does this text fit on one line THERE.
|
|
78
|
+
const compact = compactWidth.current;
|
|
79
|
+
if (compact === null || node.getBoundingClientRect().width <= compact) {
|
|
80
|
+
compactWidth.current = node.getBoundingClientRect().width;
|
|
81
|
+
setMultiline(rowsAt() > 1);
|
|
82
|
+
} else {
|
|
83
|
+
const restore = node.style.width;
|
|
84
|
+
node.style.width = `${compact}px`;
|
|
85
|
+
const wraps = rowsAt() > 1;
|
|
86
|
+
node.style.width = restore;
|
|
87
|
+
setMultiline(wraps);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The browser already sized the box from its content — measuring and
|
|
92
|
+
// writing a pixel height on top would fight it, and writing `height` at all
|
|
93
|
+
// defeats `field-sizing` outright.
|
|
94
|
+
if (FIELD_SIZING) return;
|
|
95
|
+
// Collapse first: scrollHeight never shrinks while the box is already tall
|
|
96
|
+
// enough to hold the old text, so measuring without this makes the textarea
|
|
97
|
+
// a one-way ratchet that only ever grows.
|
|
98
|
+
node.style.height = "auto";
|
|
99
|
+
const style = window.getComputedStyle(node);
|
|
100
|
+
const lineHeight = Number.parseFloat(style.lineHeight);
|
|
101
|
+
const vertical =
|
|
102
|
+
Number.parseFloat(style.paddingTop) +
|
|
103
|
+
Number.parseFloat(style.paddingBottom) +
|
|
104
|
+
Number.parseFloat(style.borderTopWidth) +
|
|
105
|
+
Number.parseFloat(style.borderBottomWidth);
|
|
106
|
+
// `lineHeight: normal` parses to NaN; then there is no row unit to cap by,
|
|
107
|
+
// and an unbounded box beats a box collapsed to zero.
|
|
108
|
+
const cap = Number.isNaN(lineHeight) ? Number.POSITIVE_INFINITY : lineHeight * maxRows + vertical;
|
|
109
|
+
const next = Math.min(node.scrollHeight, cap);
|
|
110
|
+
node.style.height = `${next}px`;
|
|
111
|
+
node.style.overflowY = node.scrollHeight > cap ? "auto" : "hidden";
|
|
112
|
+
}, [ref, maxRows]);
|
|
113
|
+
|
|
114
|
+
useLayoutEffect(resize, [resize, text]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Follow the caret once the field scrolls internally.
|
|
118
|
+
*
|
|
119
|
+
* The browser does this for typing but NOT for a programmatic write, which is
|
|
120
|
+
* what dictation is — `setText` from a speech callback left the box frozen
|
|
121
|
+
* while the transcript grew past it.
|
|
122
|
+
*/
|
|
123
|
+
useLayoutEffect(() => {
|
|
124
|
+
const node = ref.current;
|
|
125
|
+
if (node === null) return;
|
|
126
|
+
if (node.scrollHeight <= node.clientHeight) return;
|
|
127
|
+
// Only with the caret at the end: mid-text it would yank the view away from
|
|
128
|
+
// what the user is editing.
|
|
129
|
+
if (node.selectionEnd !== node.value.length) return;
|
|
130
|
+
node.scrollTop = node.scrollHeight;
|
|
131
|
+
}, [ref, text]);
|
|
132
|
+
|
|
133
|
+
return multiline;
|
|
134
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put the caret back in the composer's field, at the END of the draft.
|
|
3
|
+
*
|
|
4
|
+
* Two controls need this and neither owns the textarea: the form's padding
|
|
5
|
+
* (a press on empty space means "keep typing") and the tool menu (a pick is a
|
|
6
|
+
* statement of intent, and the sentence naming its object comes next). The
|
|
7
|
+
* field's own ref is deliberately local to `ComposerField`, so the shared route
|
|
8
|
+
* is the DOM — both callers render inside the same `<form>`.
|
|
9
|
+
*
|
|
10
|
+
* Kept in one place because the "caret at the end" half is the part that goes
|
|
11
|
+
* wrong: `focus()` alone leaves it wherever the browser last had it, which on a
|
|
12
|
+
* written draft is the beginning.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Focus the composer's textarea within `root`, caret after the last character.
|
|
17
|
+
*
|
|
18
|
+
* A no-op when the field already holds focus — re-running the selection would
|
|
19
|
+
* fight a caret the user just placed by clicking.
|
|
20
|
+
*/
|
|
21
|
+
export function focusComposerField(root: HTMLElement | null | undefined): void {
|
|
22
|
+
const field = root?.querySelector("textarea");
|
|
23
|
+
if (!field || field === document.activeElement) return;
|
|
24
|
+
field.focus();
|
|
25
|
+
const end = field.value.length;
|
|
26
|
+
field.setSelectionRange(end, end);
|
|
27
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The input subsystem — its own layer, not a leaf inside primitives/.
|
|
2
|
+
//
|
|
3
|
+
// Measured: the archived chat's composer is 3 200 lines across 21 files and the
|
|
4
|
+
// editor it wraps another 3 191. Seven thousand lines is a subsystem.
|
|
5
|
+
//
|
|
6
|
+
// EDITOR POLICY: textarea first, a richer editor only on a named trigger.
|
|
7
|
+
// Formatting while typing is out of scope; the one structural weakness is that
|
|
8
|
+
// a textarea cannot host a POPUP well, and a mention menu is a popup — so the
|
|
9
|
+
// switch criteria are things a textarea cannot do, never a taste argument.
|
|
10
|
+
//
|
|
11
|
+
// What keeps that swap cheap: mention DETECTION is a pure function of
|
|
12
|
+
// (text, caret) and lives in core/, so it survives the swap untouched. Only the
|
|
13
|
+
// rendering surface is replaced.
|
|
14
|
+
//
|
|
15
|
+
// LAYOUT, grouped by job rather than by artifact kind:
|
|
16
|
+
// field/ the textarea and everything positioned against its caret
|
|
17
|
+
// controls/ the row's buttons, which agree on shape through `button.tsx`
|
|
18
|
+
// tray/ what sits around the draft rather than in it
|
|
19
|
+
// stories/ the workbench; no product code may import it
|
|
20
|
+
// At the root: the shell, the context all three groups read, and this barrel.
|
|
21
|
+
// A context inside a group would make the other three reach sideways into it.
|
|
22
|
+
//
|
|
23
|
+
// May import: primitives, store, core, utils, content.
|
|
24
|
+
// May NOT import: ui, widgets.
|
|
25
|
+
|
|
26
|
+
export { Composer } from "./composer";
|
|
27
|
+
export type { ComposerProps } from "./composer";
|
|
28
|
+
export { ComposerButton } from "./controls/button";
|
|
29
|
+
export type { ComposerButtonProps, ComposerButtonTone } from "./controls/button";
|
|
30
|
+
// Exported so a host's `toolbarEnd` control gets the row's ONE tooltip shape
|
|
31
|
+
// rather than reaching for the status bar's.
|
|
32
|
+
export { ComposerTooltip } from "./controls/tooltip";
|
|
33
|
+
export type { ComposerTooltipProps } from "./controls/tooltip";
|
|
34
|
+
export { ComposerInput } from "./field/input";
|
|
35
|
+
export type { ComposerInputProps } from "./field/input";
|
|
36
|
+
export { ComposerField } from "./field/field";
|
|
37
|
+
export type { ComposerFieldProps } from "./field/field";
|
|
38
|
+
export { ComposerPickedTools, PickedToolChip } from "./tray/picked-tools";
|
|
39
|
+
export type { ComposerPickedToolsProps, PickedToolChipProps } from "./tray/picked-tools";
|
|
40
|
+
export { ComposerSend } from "./controls/send";
|
|
41
|
+
export type { ComposerSendProps } from "./controls/send";
|
|
42
|
+
export { ComposerQueue } from "./tray/queue";
|
|
43
|
+
export type { ComposerQueueProps } from "./tray/queue";
|
|
44
|
+
|
|
45
|
+
// The notice block ABOVE the composer box — composed parts, so a host adds a
|
|
46
|
+
// line or a link in its own JSX rather than asking for a prop.
|
|
47
|
+
export {
|
|
48
|
+
ComposerBanner,
|
|
49
|
+
ComposerBannerAction,
|
|
50
|
+
ComposerBannerActions,
|
|
51
|
+
ComposerBannerBody,
|
|
52
|
+
ComposerBannerDescription,
|
|
53
|
+
ComposerBannerDismiss,
|
|
54
|
+
ComposerBannerIcon,
|
|
55
|
+
ComposerBannerTitle,
|
|
56
|
+
} from "./tray/banner";
|
|
57
|
+
export type {
|
|
58
|
+
ComposerBannerActionProps,
|
|
59
|
+
ComposerBannerDismissProps,
|
|
60
|
+
ComposerBannerIconProps,
|
|
61
|
+
ComposerBannerProps,
|
|
62
|
+
ComposerBannerTone,
|
|
63
|
+
} from "./tray/banner";
|
|
64
|
+
// A session-control verb in flight. Beside the banner because it renders as
|
|
65
|
+
// one, and above the composer for the same reason.
|
|
66
|
+
export { CommandProgress } from "./tray/command-progress";
|
|
67
|
+
export type { CommandProgressProps } from "./tray/command-progress";
|
|
68
|
+
export { ComposerVoice } from "./controls/voice";
|
|
69
|
+
export type { ComposerVoiceProps } from "./controls/voice";
|
|
70
|
+
export { ComposerThink } from "./controls/think";
|
|
71
|
+
export type { ComposerThinkProps } from "./controls/think";
|
|
72
|
+
// The `+` arsenal. Its sheet is a DIRECTORY beside `controls/` — a filter, a
|
|
73
|
+
// grouped list and their matching rule do not fit one 300-line file, and
|
|
74
|
+
// splitting after the fact is how a file ends up owning three jobs.
|
|
75
|
+
export { ComposerTools } from "./controls/tools";
|
|
76
|
+
export type { ComposerToolsProps } from "./controls/tools";
|
|
77
|
+
export { ToolMenu } from "./tools/menu";
|
|
78
|
+
export type { ToolMenuProps } from "./tools/menu";
|
|
79
|
+
export { ToolRows } from "./tools/rows";
|
|
80
|
+
export type { ToolRowsProps } from "./tools/rows";
|
|
81
|
+
export { LanguageMenu, languageLabel } from "./controls/language-menu";
|
|
82
|
+
export type { LanguageMenuProps } from "./controls/language-menu";
|
|
83
|
+
export { useComposer } from "./context";
|
|
84
|
+
export { useDraftPersistence } from "./use-draft-persistence";
|
|
85
|
+
export { useReplaceText } from "./tray/use-replace-text";
|
|
86
|
+
export type { AskReplace } from "./tray/use-replace-text";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
import { useRecall } from "../../store";
|
|
4
|
+
import { ComposerInput } from "../field/input";
|
|
5
|
+
import { useComposer } from "../context";
|
|
6
|
+
import { useDraftPersistence } from "../use-draft-persistence";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The composer's FIELD, for stories: the textarea and ↑/↓ recall over it.
|
|
10
|
+
*
|
|
11
|
+
* Separate from `harness.tsx` because the seam is real, not because a limit
|
|
12
|
+
* forced it: everything here is the field assembly, and the harness above it is
|
|
13
|
+
* the surrounding chrome.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const field = "w-full resize-none bg-transparent text-foreground outline-none";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The field wiring, assembled independently of the shipped assembly.
|
|
20
|
+
*
|
|
21
|
+
* `field/field.tsx` is the shipped join; this is a SECOND, minimal one — which
|
|
22
|
+
* is the point. A contract exercised in exactly one place is a contract nobody
|
|
23
|
+
* has tested against a different caller. If the pieces only fit one way, they
|
|
24
|
+
* fail to fit here, visibly.
|
|
25
|
+
*/
|
|
26
|
+
export function Field({
|
|
27
|
+
initialText,
|
|
28
|
+
sendOnMount,
|
|
29
|
+
history,
|
|
30
|
+
draftRoom,
|
|
31
|
+
}: {
|
|
32
|
+
readonly initialText?: string;
|
|
33
|
+
readonly sendOnMount: boolean;
|
|
34
|
+
readonly history?: readonly string[];
|
|
35
|
+
/** Persist the draft under this room. Absent ⇒ no persistence, as in a host. */
|
|
36
|
+
readonly draftRoom?: string;
|
|
37
|
+
}) {
|
|
38
|
+
const { draft, setText, send } = useComposer();
|
|
39
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
40
|
+
|
|
41
|
+
// The ladder GROWS as prompts are sent, because a fixed fixture would show
|
|
42
|
+
// recall working over data no user action produced — and the interesting bug
|
|
43
|
+
// (a sent prompt not reaching the ladder) would be invisible.
|
|
44
|
+
const [sentPrompts, setSentPrompts] = useState<readonly string[]>(history ?? []);
|
|
45
|
+
const recall = useRecall({ entries: sentPrompts, input: ref, draft, setText });
|
|
46
|
+
|
|
47
|
+
// BEFORE the seed: effects run in call order, so a restore lands first and an
|
|
48
|
+
// explicit `initialText` overrides it. To SEE a restore, pass no seed.
|
|
49
|
+
useDraftPersistence(draftRoom);
|
|
50
|
+
useSeed(initialText, setText);
|
|
51
|
+
// AFTER the seed has reached the store, never beside it: `useSeed` writes
|
|
52
|
+
// during render, so a send issued in the same pass would post an empty draft
|
|
53
|
+
// and the story would park on an idle Send having proved nothing.
|
|
54
|
+
useSendOnce(sendOnMount && draft.text.length > 0, send);
|
|
55
|
+
|
|
56
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
57
|
+
// Record the prompt on Enter, BEFORE the composer clears the draft — after
|
|
58
|
+
// it there is nothing left to record. A real host derives this from the
|
|
59
|
+
// transcript (`core/recall/from-transcript.ts`); a story has none.
|
|
60
|
+
if (event.key === "Enter" && !event.shiftKey && draft.text.trim() !== "") {
|
|
61
|
+
const text = draft.text;
|
|
62
|
+
setSentPrompts((prev) => [...prev, text]);
|
|
63
|
+
recall.reset();
|
|
64
|
+
}
|
|
65
|
+
recall.onKeyDown(event);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="relative grid gap-1">
|
|
70
|
+
<ComposerInput
|
|
71
|
+
label="Message"
|
|
72
|
+
placeholder="Ask anything…"
|
|
73
|
+
className={field}
|
|
74
|
+
ref={ref}
|
|
75
|
+
onKeyDown={onKeyDown}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send exactly once, in an effect, when `ready` first becomes true.
|
|
83
|
+
*
|
|
84
|
+
* An effect rather than the render-phase shortcut `useSeed` uses: `send()`
|
|
85
|
+
* starts a turn and writes to the store, which during render would be a state
|
|
86
|
+
* update in another component's render pass. Seeding text is idempotent; posting
|
|
87
|
+
* a turn is not.
|
|
88
|
+
*/
|
|
89
|
+
function useSendOnce(ready: boolean, send: () => void) {
|
|
90
|
+
const done = useRef(false);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!ready || done.current) return;
|
|
93
|
+
done.current = true;
|
|
94
|
+
send();
|
|
95
|
+
}, [ready, send]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Seed the field once, in an effect.
|
|
100
|
+
*
|
|
101
|
+
* `setText` writes to the composer store, so doing it during render updated
|
|
102
|
+
* `Composer` from inside `Field`'s render pass — React's
|
|
103
|
+
* "Cannot update a component while rendering a different component".
|
|
104
|
+
*/
|
|
105
|
+
function useSeed(text: string | undefined, setText: (value: string) => void) {
|
|
106
|
+
const done = useRef(false);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (done.current || text === undefined) return;
|
|
109
|
+
done.current = true;
|
|
110
|
+
setText(text);
|
|
111
|
+
}, [text, setText]);
|
|
112
|
+
}
|