@theoremjs/react 0.3.4-next.851.g9ebce61 → 0.3.4-next.853.gebc3c60
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 +2 -0
- package/package.json +3 -3
- package/ui/ChatComposerBar.js +14 -6
- package/ui/ComposerControls.d.ts +16 -0
- package/ui/ComposerControls.js +70 -0
package/README.md
CHANGED
|
@@ -479,6 +479,8 @@ function MyChat() {
|
|
|
479
479
|
|
|
480
480
|
To show the transcript and the composer, use `ChatTranscript` and `ChatComposerBar` from `/ui`. You can also draw your own from `chat.blocks`.
|
|
481
481
|
|
|
482
|
+
The composer bar has a selector for the model, its effort, and each option. When their values do not fit the bar, each selector shows only its icon, and a tooltip gives its name and value. When the icons do not fit either, the row scrolls inside the composer. A control you pass as `generationSelect` keeps its own look and scrolls with the row.
|
|
483
|
+
|
|
482
484
|
`ChatTranscript` places `citation` blocks after the answer text by default (the Sources strip under the prose). Pass `bodyKindPlacement={{}}` when citations must stay in stream order between text segments (inline citations).
|
|
483
485
|
|
|
484
486
|
The hooks and the client write no English text. They return kinds, codes, and states. You write the words.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theoremjs/react",
|
|
3
|
-
"version": "0.3.4-next.
|
|
3
|
+
"version": "0.3.4-next.853.gebc3c60",
|
|
4
4
|
"description": "React bindings for Theorem: headless hooks + transport, an Astryx chat UI, and a Web-standard server handler.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"@stylexjs/stylex": "0.19.0",
|
|
61
61
|
"react": "^19.0.0",
|
|
62
62
|
"react-dom": "^19.0.0",
|
|
63
|
-
"@theoremjs/agents": "^0.3.4-next.
|
|
64
|
-
"@theoremjs/web": "^0.3.4-next.
|
|
63
|
+
"@theoremjs/agents": "^0.3.4-next.853.gebc3c60",
|
|
64
|
+
"@theoremjs/web": "^0.3.4-next.853.gebc3c60"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@astryxdesign/core": {
|
package/ui/ChatComposerBar.js
CHANGED
|
@@ -5,7 +5,6 @@ import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
|
|
5
5
|
import { HStack } from '@astryxdesign/core/HStack';
|
|
6
6
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
|
7
7
|
import { Kbd } from '@astryxdesign/core/Kbd';
|
|
8
|
-
import { Selector } from '@astryxdesign/core/Selector';
|
|
9
8
|
import { StackItem } from '@astryxdesign/core/Stack';
|
|
10
9
|
import { Text } from '@astryxdesign/core/Text';
|
|
11
10
|
import { Thumbnail } from '@astryxdesign/core/Thumbnail';
|
|
@@ -20,6 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
|
20
19
|
import { stageComposerFiles } from "../client/composer-attachments.js";
|
|
21
20
|
import { useComposerVoice } from "../components/use-composer-voice.js";
|
|
22
21
|
import { useEditorSelection } from "../components/use-editor-selection.js";
|
|
22
|
+
import { ComposerControls, ComposerSelector } from "./ComposerControls.js";
|
|
23
23
|
import { composerDrawerLabel } from "./labels.js";
|
|
24
24
|
import { TheoremLabelsProvider, useLabels } from "./labels-provider.js";
|
|
25
25
|
import { VoiceNote } from "./VoiceNote.js";
|
|
@@ -78,7 +78,7 @@ export function generationOptions(iface, selectedModel) {
|
|
|
78
78
|
function ModelSelector(props) {
|
|
79
79
|
if (props.models.length === 0)
|
|
80
80
|
return null;
|
|
81
|
-
return (_jsx(
|
|
81
|
+
return (_jsx(ComposerSelector, { label: props.label, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(IconInputAi, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: props.value ?? '', options: props.models.map((m) => ({
|
|
82
82
|
value: m.id,
|
|
83
83
|
label: m.id,
|
|
84
84
|
description: m.label !== m.id ? m.label : undefined,
|
|
@@ -87,7 +87,7 @@ function ModelSelector(props) {
|
|
|
87
87
|
function EffortSelector(props) {
|
|
88
88
|
if (props.efforts.length === 0)
|
|
89
89
|
return null;
|
|
90
|
-
return (_jsx(
|
|
90
|
+
return (_jsx(ComposerSelector, { label: props.label, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(EFFORT_ICON, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: props.value ?? '', options: props.efforts.map((e) => ({ value: e.alias, label: e.alias, description: e.level })), onChange: props.onChange }));
|
|
91
91
|
}
|
|
92
92
|
function OptionSelectors(props) {
|
|
93
93
|
if (!props.choices)
|
|
@@ -103,17 +103,25 @@ function OptionSelectors(props) {
|
|
|
103
103
|
label: choice.value,
|
|
104
104
|
description: choice.description,
|
|
105
105
|
}));
|
|
106
|
-
return (_jsx(
|
|
106
|
+
return (_jsx(ComposerSelector, { label: title, isLabelHidden: true, size: "sm", variant: "ghost", startIcon: _jsx(IconAdjustmentsHorizontal, { size: 14 }), placement: "above", isDisabled: props.isDisabled, value: values[name] ?? '', options: [{ type: 'section', title, options: choices }], onChange: (value) => onChange(name, value) }, name));
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
function GenerationSelect(props) {
|
|
110
110
|
const { models, efforts } = generationOptions(props.iface, props.selectedModel);
|
|
111
111
|
const optionCount = Object.keys(props.optionChoices?.declared ?? {}).length;
|
|
112
|
+
// invariant: What the controls show; when it changes they unfold and measure again.
|
|
113
|
+
const fit = JSON.stringify([
|
|
114
|
+
props.control
|
|
115
|
+
? null
|
|
116
|
+
: [models.length, efforts.length, props.selectedModel, props.selectedEffort],
|
|
117
|
+
props.optionChoices?.values,
|
|
118
|
+
optionCount,
|
|
119
|
+
]);
|
|
112
120
|
if (props.control)
|
|
113
|
-
return (_jsxs(
|
|
121
|
+
return (_jsxs(ComposerControls, { fit: fit, children: [props.control, _jsx(OptionSelectors, { choices: props.optionChoices, isDisabled: props.isDisabled })] }));
|
|
114
122
|
if (models.length + efforts.length + optionCount === 0)
|
|
115
123
|
return null;
|
|
116
|
-
return (_jsxs(
|
|
124
|
+
return (_jsxs(ComposerControls, { fit: fit, children: [_jsx(ModelSelector, { label: labelText('composer.model', {}, props.iface.labels), models: models, value: props.selectedModel, isDisabled: props.isDisabled, onChange: (modelId) => props.onChange?.({ modelId }) }), _jsx(EffortSelector, { label: labelText('composer.effort', {}, props.iface.labels), efforts: efforts, value: props.selectedEffort, isDisabled: props.isDisabled, onChange: (effort) => {
|
|
117
125
|
if (props.selectedModel)
|
|
118
126
|
props.onChange?.({ modelId: props.selectedModel, effort });
|
|
119
127
|
} }), _jsx(OptionSelectors, { choices: props.optionChoices, isDisabled: props.isDisabled })] }));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SelectorProps } from '@astryxdesign/core/Selector';
|
|
2
|
+
import { type ReactNode } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* The composer's footer controls. Their labels fold to icons, each with its name and value in a
|
|
5
|
+
* tooltip, when the labels do not fit the row; if the icons do not fit either, the row scrolls.
|
|
6
|
+
* `fit` names what the controls show: when it changes they unfold and measure again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ComposerControls(props: {
|
|
9
|
+
fit: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
/**
|
|
13
|
+
* A footer selector. Folded, it shows only its icon, and a tooltip gives its name and value; the
|
|
14
|
+
* name stays its accessible label either way.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ComposerSelector(selector: SelectorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { HStack } from '@astryxdesign/core/HStack';
|
|
3
|
+
import { Selector } from '@astryxdesign/core/Selector';
|
|
4
|
+
import { StackItem } from '@astryxdesign/core/Stack';
|
|
5
|
+
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
|
6
|
+
import { createContext, useContext, useLayoutEffect, useReducer, useRef, useState, } from 'react';
|
|
7
|
+
const CompactContext = createContext(false);
|
|
8
|
+
/** The footer's controls scroll inside their room when even their icons do not fit. */
|
|
9
|
+
const ROW_SCROLL = { minWidth: 0, overflowX: 'auto', scrollbarWidth: 'thin' };
|
|
10
|
+
/**
|
|
11
|
+
* The room the composer's footer row leaves its controls: the row's content box, less the send
|
|
12
|
+
* cluster beside them and the gap between.
|
|
13
|
+
* why: Astryx wraps `footerActions` in a box that cannot shrink below its content, so the
|
|
14
|
+
* controls take a measured `max-width`, which also caps what they ask of that box.
|
|
15
|
+
*/
|
|
16
|
+
function footerRoom(controls) {
|
|
17
|
+
const row = controls.parentElement?.parentElement;
|
|
18
|
+
const send = row?.lastElementChild;
|
|
19
|
+
if (!row || !(send instanceof HTMLElement) || send === controls.parentElement)
|
|
20
|
+
return null;
|
|
21
|
+
const style = getComputedStyle(row);
|
|
22
|
+
const inner = row.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
|
|
23
|
+
return Math.max(0, inner - send.offsetWidth - (Number.parseFloat(style.columnGap) || 0));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The composer's footer controls. Their labels fold to icons, each with its name and value in a
|
|
27
|
+
* tooltip, when the labels do not fit the row; if the icons do not fit either, the row scrolls.
|
|
28
|
+
* `fit` names what the controls show: when it changes they unfold and measure again.
|
|
29
|
+
*/
|
|
30
|
+
export function ComposerControls(props) {
|
|
31
|
+
const ref = useRef(null);
|
|
32
|
+
const [room, setRoom] = useState(null);
|
|
33
|
+
// invariant: The width the labels need, measured when they last overflowed; null while they fit.
|
|
34
|
+
const [needed, setNeeded] = useState(null);
|
|
35
|
+
const [, remeasure] = useReducer((n) => n + 1, 0);
|
|
36
|
+
const compact = needed !== null && needed.fit === props.fit && (room ?? 0) < needed.width;
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
const el = ref.current;
|
|
39
|
+
const row = el?.parentElement?.parentElement;
|
|
40
|
+
if (!el || !row)
|
|
41
|
+
return;
|
|
42
|
+
const observer = new ResizeObserver(remeasure);
|
|
43
|
+
observer.observe(row);
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
}, []);
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
const el = ref.current;
|
|
48
|
+
if (!el)
|
|
49
|
+
return;
|
|
50
|
+
const next = footerRoom(el);
|
|
51
|
+
if (next !== room) {
|
|
52
|
+
setRoom(next);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!compact && el.scrollWidth > el.clientWidth) {
|
|
56
|
+
setNeeded({ fit: props.fit, width: el.scrollWidth });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return (_jsx(CompactContext.Provider, { value: compact, children: _jsx(HStack, { ref: ref, gap: 1, style: room === null ? ROW_SCROLL : { ...ROW_SCROLL, maxWidth: room }, children: props.children }) }));
|
|
60
|
+
}
|
|
61
|
+
const NO_VALUE = () => null;
|
|
62
|
+
/**
|
|
63
|
+
* A footer selector. Folded, it shows only its icon, and a tooltip gives its name and value; the
|
|
64
|
+
* name stays its accessible label either way.
|
|
65
|
+
*/
|
|
66
|
+
export function ComposerSelector(selector) {
|
|
67
|
+
const compact = useContext(CompactContext);
|
|
68
|
+
const value = selector.value;
|
|
69
|
+
return (_jsx(StackItem, { size: "static", children: _jsx(Tooltip, { isEnabled: compact, content: value ? `${selector.label}: ${value}` : selector.label, hasHoverIndication: false, children: _jsx(Selector, { ...selector, renderValue: compact ? NO_VALUE : selector.renderValue }) }) }));
|
|
70
|
+
}
|