@dxos/react-ui-chat 0.8.4-main.1068cf700f
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 +8 -0
- package/README.md +1 -0
- package/dist/lib/browser/index.mjs +288 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +290 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/components/ChatDialog/ChatDialog.d.ts +34 -0
- package/dist/types/src/components/ChatDialog/ChatDialog.d.ts.map +1 -0
- package/dist/types/src/components/ChatDialog/ChatDialog.stories.d.ts +38 -0
- package/dist/types/src/components/ChatDialog/ChatDialog.stories.d.ts.map +1 -0
- package/dist/types/src/components/ChatDialog/index.d.ts +2 -0
- package/dist/types/src/components/ChatDialog/index.d.ts.map +1 -0
- package/dist/types/src/components/ChatEditor/ChatEditor.d.ts +20 -0
- package/dist/types/src/components/ChatEditor/ChatEditor.d.ts.map +1 -0
- package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts +25 -0
- package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/ChatEditor/index.d.ts +3 -0
- package/dist/types/src/components/ChatEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/ChatEditor/references.d.ts +34 -0
- package/dist/types/src/components/ChatEditor/references.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +3 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +7 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +78 -0
- package/src/components/ChatDialog/ChatDialog.stories.tsx +64 -0
- package/src/components/ChatDialog/ChatDialog.tsx +199 -0
- package/src/components/ChatDialog/index.ts +5 -0
- package/src/components/ChatEditor/ChatEditor.stories.tsx +97 -0
- package/src/components/ChatEditor/ChatEditor.tsx +57 -0
- package/src/components/ChatEditor/index.ts +7 -0
- package/src/components/ChatEditor/references.ts +181 -0
- package/src/components/index.ts +6 -0
- package/src/index.ts +6 -0
- package/src/translations.ts +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/react-ui-chat",
|
|
3
|
+
"version": "0.8.4-main.1068cf700f",
|
|
4
|
+
"description": "Chat components.",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "DXOS.org",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/types/src/index.d.ts",
|
|
18
|
+
"browser": "./dist/lib/browser/index.mjs",
|
|
19
|
+
"node": "./dist/lib/node-esm/index.mjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"types": "dist/types/src/index.d.ts",
|
|
23
|
+
"typesVersions": {
|
|
24
|
+
"*": {}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@codemirror/autocomplete": "^6.19.0",
|
|
32
|
+
"@codemirror/language": "^6.11.3",
|
|
33
|
+
"@codemirror/search": "^6.5.11",
|
|
34
|
+
"@codemirror/state": "^6.5.2",
|
|
35
|
+
"@codemirror/view": "^6.38.4",
|
|
36
|
+
"@fluentui/react-tabster": "9.26.11",
|
|
37
|
+
"@radix-ui/react-accordion": "1.2.3",
|
|
38
|
+
"@radix-ui/react-context": "1.1.1",
|
|
39
|
+
"@dxos/debug": "0.8.4-main.1068cf700f",
|
|
40
|
+
"@dxos/echo": "0.8.4-main.1068cf700f",
|
|
41
|
+
"@dxos/async": "0.8.4-main.1068cf700f",
|
|
42
|
+
"@dxos/log": "0.8.4-main.1068cf700f",
|
|
43
|
+
"@dxos/react-ui": "0.8.4-main.1068cf700f",
|
|
44
|
+
"@dxos/invariant": "0.8.4-main.1068cf700f",
|
|
45
|
+
"@dxos/react-ui-components": "0.8.4-main.1068cf700f",
|
|
46
|
+
"@dxos/react-ui-dnd": "0.8.4-main.1068cf700f",
|
|
47
|
+
"@dxos/react-ui-editor": "0.8.4-main.1068cf700f",
|
|
48
|
+
"@dxos/react-ui-syntax-highlighter": "0.8.4-main.1068cf700f",
|
|
49
|
+
"@dxos/ui-editor": "0.8.4-main.1068cf700f",
|
|
50
|
+
"@dxos/react-ui-sfx": "0.8.4-main.1068cf700f",
|
|
51
|
+
"@dxos/ui-theme": "0.8.4-main.1068cf700f",
|
|
52
|
+
"@dxos/react-ui-text-tooltip": "0.8.4-main.1068cf700f",
|
|
53
|
+
"@dxos/ui-types": "0.8.4-main.1068cf700f",
|
|
54
|
+
"@dxos/util": "0.8.4-main.1068cf700f"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/react": "~19.2.7",
|
|
58
|
+
"@types/react-dom": "~19.2.3",
|
|
59
|
+
"effect": "3.19.16",
|
|
60
|
+
"react": "~19.2.3",
|
|
61
|
+
"react-dom": "~19.2.3",
|
|
62
|
+
"vite": "7.1.9",
|
|
63
|
+
"@dxos/storybook-utils": "0.8.4-main.1068cf700f",
|
|
64
|
+
"@dxos/react-client": "0.8.4-main.1068cf700f",
|
|
65
|
+
"@dxos/random": "0.8.4-main.1068cf700f",
|
|
66
|
+
"@dxos/echo": "0.8.4-main.1068cf700f"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"effect": "3.19.16",
|
|
70
|
+
"react": "~19.2.3",
|
|
71
|
+
"react-dom": "~19.2.3",
|
|
72
|
+
"@dxos/react-ui": "0.8.4-main.1068cf700f",
|
|
73
|
+
"@dxos/ui-theme": "0.8.4-main.1068cf700f"
|
|
74
|
+
},
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"access": "public"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Input, Toolbar } from '@dxos/react-ui';
|
|
9
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
10
|
+
|
|
11
|
+
import { translations } from '../../translations';
|
|
12
|
+
|
|
13
|
+
import { ChatDialog } from './ChatDialog';
|
|
14
|
+
|
|
15
|
+
const items = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: 'ui/react-ui-chat/ChatDialog',
|
|
19
|
+
component: ChatDialog.Root,
|
|
20
|
+
render: (args) => {
|
|
21
|
+
const [open, setOpen] = useState(true);
|
|
22
|
+
const [expanded, setExpanded] = useState(true);
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Toolbar.Root>
|
|
26
|
+
<Toolbar.Button onClick={() => setOpen((open) => !open)}>Open</Toolbar.Button>
|
|
27
|
+
<Toolbar.Button onClick={() => setExpanded((expanded) => !expanded)}>Expand</Toolbar.Button>
|
|
28
|
+
</Toolbar.Root>
|
|
29
|
+
|
|
30
|
+
<ChatDialog.Root
|
|
31
|
+
{...args}
|
|
32
|
+
open={open}
|
|
33
|
+
onOpenChange={setOpen}
|
|
34
|
+
expanded={expanded}
|
|
35
|
+
onExpandedChange={setExpanded}
|
|
36
|
+
>
|
|
37
|
+
<ChatDialog.Header title='Chat' />
|
|
38
|
+
<ChatDialog.Content>
|
|
39
|
+
{items.map((item) => (
|
|
40
|
+
<div key={item} className='pis-4 pbe-1'>
|
|
41
|
+
{item}
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</ChatDialog.Content>
|
|
45
|
+
<ChatDialog.Footer classNames='pli-2 items-center'>
|
|
46
|
+
<Input.Root>
|
|
47
|
+
<Input.TextInput classNames='border-none' placeholder='Test' />
|
|
48
|
+
</Input.Root>
|
|
49
|
+
</ChatDialog.Footer>
|
|
50
|
+
</ChatDialog.Root>
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
55
|
+
parameters: {
|
|
56
|
+
translations,
|
|
57
|
+
},
|
|
58
|
+
} satisfies Meta<typeof ChatDialog.Root>;
|
|
59
|
+
|
|
60
|
+
export default meta;
|
|
61
|
+
|
|
62
|
+
type Story = StoryObj<typeof meta>;
|
|
63
|
+
|
|
64
|
+
export const Default: Story = {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createContext } from '@radix-ui/react-context';
|
|
6
|
+
import React, { type Dispatch, type PropsWithChildren, type SetStateAction, useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Dialog, Icon, IconButton, type ThemedClassName, useControlledState } from '@dxos/react-ui';
|
|
9
|
+
import { ResizeHandle, type Size, resizeAttributes, sizeStyle } from '@dxos/react-ui-dnd';
|
|
10
|
+
import { mx } from '@dxos/ui-theme';
|
|
11
|
+
|
|
12
|
+
const preventDefault = (event: Event) => event.preventDefault();
|
|
13
|
+
|
|
14
|
+
// TODO(burdon): Factor out.
|
|
15
|
+
const Endcap = ({ children }: PropsWithChildren) => {
|
|
16
|
+
return (
|
|
17
|
+
<div className='grid is-[var(--rail-action)] bs-[var(--rail-action)] items-center justify-center'>{children}</div>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//
|
|
22
|
+
// Context
|
|
23
|
+
//
|
|
24
|
+
|
|
25
|
+
type ChatDialogContextValue = {
|
|
26
|
+
open: boolean;
|
|
27
|
+
setOpen: Dispatch<SetStateAction<boolean>>;
|
|
28
|
+
expanded: boolean;
|
|
29
|
+
setExpanded: Dispatch<SetStateAction<boolean>>;
|
|
30
|
+
size: Size;
|
|
31
|
+
setSize: Dispatch<SetStateAction<Size>>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const [ChatDialogContextProvider, useChatDialogContext] = createContext<ChatDialogContextValue>('ChatDialog');
|
|
35
|
+
|
|
36
|
+
//
|
|
37
|
+
// Root
|
|
38
|
+
//
|
|
39
|
+
|
|
40
|
+
type ChatDialogRootProps = PropsWithChildren<{
|
|
41
|
+
open?: boolean;
|
|
42
|
+
expanded?: boolean;
|
|
43
|
+
onOpenChange?: (open: boolean) => void;
|
|
44
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
45
|
+
onEscape?: () => void;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
const ChatDialogRoot = ({
|
|
49
|
+
children,
|
|
50
|
+
open: openProp = false,
|
|
51
|
+
expanded: expandedProp = false,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
onExpandedChange,
|
|
54
|
+
onEscape,
|
|
55
|
+
}: ChatDialogRootProps) => {
|
|
56
|
+
const [size, setSize] = useState<Size>('min-content');
|
|
57
|
+
const [open, setOpen] = useControlledState<boolean>(openProp, onOpenChange);
|
|
58
|
+
const [expanded, setExpanded] = useControlledState<boolean>(expandedProp, onExpandedChange);
|
|
59
|
+
|
|
60
|
+
// NOTE: We set the min size to 5rem (80px), and the header and prompt bar to 40px (i.e., the rail-size) each.
|
|
61
|
+
// The dialog has no vertical padding and has box-content so that when closed it collapses to the size of the header and prompt bar.
|
|
62
|
+
return (
|
|
63
|
+
<ChatDialogContextProvider
|
|
64
|
+
open={open}
|
|
65
|
+
setOpen={setOpen}
|
|
66
|
+
expanded={expanded}
|
|
67
|
+
setExpanded={setExpanded}
|
|
68
|
+
size={size}
|
|
69
|
+
setSize={setSize}
|
|
70
|
+
>
|
|
71
|
+
<Dialog.Root modal={false} open={open} onOpenChange={setOpen}>
|
|
72
|
+
<div role='none' className='dx-dialog__overlay bg-transparent pointer-events-none' data-block-align='end'>
|
|
73
|
+
<Dialog.Content
|
|
74
|
+
inOverlayLayout
|
|
75
|
+
classNames={[
|
|
76
|
+
'grid grid-rows-[var(--rail-action)_1fr_min-content] p-0 overflow-hidden',
|
|
77
|
+
'box-content md:is-[35rem] md:max-is-none pointer-events-auto',
|
|
78
|
+
]}
|
|
79
|
+
onEscapeKeyDown={onEscape}
|
|
80
|
+
onInteractOutside={preventDefault}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</Dialog.Content>
|
|
84
|
+
</div>
|
|
85
|
+
</Dialog.Root>
|
|
86
|
+
</ChatDialogContextProvider>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
ChatDialogRoot.displayName = 'ChatDialog.Root';
|
|
91
|
+
|
|
92
|
+
//
|
|
93
|
+
// Header
|
|
94
|
+
//
|
|
95
|
+
|
|
96
|
+
const CHAT_DIALOG_HEADER_NAME = 'ChatDialog.Header';
|
|
97
|
+
|
|
98
|
+
type ChatDialogHeaderProps = ThemedClassName<{
|
|
99
|
+
title?: string;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
const ChatDialogHeader = ({ classNames, title }: ChatDialogHeaderProps) => {
|
|
103
|
+
const { expanded, setExpanded } = useChatDialogContext(CHAT_DIALOG_HEADER_NAME);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={mx('grid grid-cols-[var(--rail-action)_1fr_min-content] items-center overflow-hidden', classNames)}>
|
|
107
|
+
<Endcap>
|
|
108
|
+
<Dialog.Close>
|
|
109
|
+
<Icon icon='ph--x--regular' />
|
|
110
|
+
</Dialog.Close>
|
|
111
|
+
</Endcap>
|
|
112
|
+
<Dialog.Title
|
|
113
|
+
classNames='flex is-full justify-center text-sm text-subdued select-none cursor-pointer'
|
|
114
|
+
onClick={() => setExpanded((expanded) => !expanded)}
|
|
115
|
+
>
|
|
116
|
+
{title}
|
|
117
|
+
</Dialog.Title>
|
|
118
|
+
<Endcap>
|
|
119
|
+
<IconButton
|
|
120
|
+
variant='ghost'
|
|
121
|
+
icon='ph--caret-up--regular'
|
|
122
|
+
iconOnly
|
|
123
|
+
classNames={mx('!p-1 [&>svg]:transition [&>svg]:duration-200', expanded && '[&>svg]:rotate-180')}
|
|
124
|
+
label={expanded ? 'Close' : 'Open'}
|
|
125
|
+
onClick={() => setExpanded((expanded) => !expanded)}
|
|
126
|
+
/>
|
|
127
|
+
</Endcap>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
ChatDialogHeader.displayName = CHAT_DIALOG_HEADER_NAME;
|
|
133
|
+
|
|
134
|
+
//
|
|
135
|
+
// Content
|
|
136
|
+
//
|
|
137
|
+
|
|
138
|
+
const CHAT_DIALOG_CONTENT_NAME = 'ChatDialog.Content';
|
|
139
|
+
|
|
140
|
+
type ChatDialogContentProps = ThemedClassName<PropsWithChildren>;
|
|
141
|
+
|
|
142
|
+
const ChatDialogContent = ({ children, classNames }: ChatDialogContentProps) => {
|
|
143
|
+
const { expanded, size, setSize } = useChatDialogContext(CHAT_DIALOG_CONTENT_NAME);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
setSize(expanded ? 'min-content' : 0);
|
|
146
|
+
}, [expanded]);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
className={mx(
|
|
151
|
+
'border-bs border-be border-subduedSeparator',
|
|
152
|
+
'transition-[block-size] ease-in-out duration-0 [&:not([data-dx-resizing="true"])]:duration-200',
|
|
153
|
+
classNames,
|
|
154
|
+
)}
|
|
155
|
+
style={{
|
|
156
|
+
...sizeStyle(size, 'vertical', true),
|
|
157
|
+
maxBlockSize: 'calc(100dvh - env(safe-area-inset-bottom) - env(safe-area-inset-top) - 14rem)',
|
|
158
|
+
}}
|
|
159
|
+
{...resizeAttributes}
|
|
160
|
+
>
|
|
161
|
+
<ResizeHandle
|
|
162
|
+
side='block-start'
|
|
163
|
+
iconPosition='center'
|
|
164
|
+
defaultSize='min-content'
|
|
165
|
+
fallbackSize={0}
|
|
166
|
+
minSize={0}
|
|
167
|
+
onSizeChange={setSize}
|
|
168
|
+
/>
|
|
169
|
+
{children}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
ChatDialogContent.displayName = CHAT_DIALOG_CONTENT_NAME;
|
|
175
|
+
|
|
176
|
+
//
|
|
177
|
+
// Footer
|
|
178
|
+
//
|
|
179
|
+
|
|
180
|
+
const CHAT_DIALOG_FOOTER_NAME = 'ChatDialog.Footer';
|
|
181
|
+
|
|
182
|
+
type ChatDialogFooterProps = ThemedClassName<PropsWithChildren>;
|
|
183
|
+
|
|
184
|
+
const ChatDialogFooter = ({ children, classNames }: ChatDialogFooterProps) => {
|
|
185
|
+
return <div className={mx(classNames)}>{children}</div>;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
ChatDialogFooter.displayName = CHAT_DIALOG_FOOTER_NAME;
|
|
189
|
+
|
|
190
|
+
//
|
|
191
|
+
// ChatDialog
|
|
192
|
+
//
|
|
193
|
+
|
|
194
|
+
export const ChatDialog = {
|
|
195
|
+
Root: ChatDialogRoot,
|
|
196
|
+
Header: ChatDialogHeader,
|
|
197
|
+
Content: ChatDialogContent,
|
|
198
|
+
Footer: ChatDialogFooter,
|
|
199
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
import { withTheme } from '@dxos/react-ui/testing';
|
|
9
|
+
|
|
10
|
+
import { translations } from '../../translations';
|
|
11
|
+
|
|
12
|
+
import { ChatEditor } from './ChatEditor';
|
|
13
|
+
import { type ReferenceData } from './references';
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: 'ui/react-ui-chat/ChatEditor',
|
|
17
|
+
component: ChatEditor,
|
|
18
|
+
decorators: [withTheme()],
|
|
19
|
+
parameters: {
|
|
20
|
+
layout: 'centered',
|
|
21
|
+
translations,
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof ChatEditor>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
|
|
27
|
+
const classNames = 'is-[20rem] p-2 border border-separator';
|
|
28
|
+
|
|
29
|
+
type Story = StoryObj<typeof meta>;
|
|
30
|
+
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
classNames,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// TODO(burdon): Restore and reconcile with suggest/typeahead.
|
|
38
|
+
export const WithSuggestions: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
classNames,
|
|
41
|
+
onSubmit: (text) => {
|
|
42
|
+
log('onSubmit', { text });
|
|
43
|
+
},
|
|
44
|
+
// onSuggest: (text) => {
|
|
45
|
+
// const trimmed = text.trim().toLowerCase();
|
|
46
|
+
// if (trimmed.length < 2) {
|
|
47
|
+
// return [];
|
|
48
|
+
// }
|
|
49
|
+
|
|
50
|
+
// const suggestions = [
|
|
51
|
+
// 'Create a kanban board',
|
|
52
|
+
// 'Create a new project',
|
|
53
|
+
// 'Find flights to Tokyo',
|
|
54
|
+
// "Let's play chess",
|
|
55
|
+
// 'Show me Paris on a map',
|
|
56
|
+
// ];
|
|
57
|
+
|
|
58
|
+
// return suggestions.filter((s) => s.toLowerCase().startsWith(text));
|
|
59
|
+
// },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// TODO(burdon): Replace.
|
|
64
|
+
const references: ReferenceData[] = [
|
|
65
|
+
{
|
|
66
|
+
uri: 'dxn:echo:@:AAAAAAAA',
|
|
67
|
+
label: 'Meeting Notes',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
uri: 'dxn:echo:@:BBBBBBBB',
|
|
71
|
+
label: 'Project Plan',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
uri: 'dxn:echo:@:CCCCCCCC',
|
|
75
|
+
label: 'Meeting Plan',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export const WithReferences: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
classNames,
|
|
82
|
+
references: {
|
|
83
|
+
provider: {
|
|
84
|
+
getReferences: async ({ query }) => {
|
|
85
|
+
const res = references.filter((i) => i.label.toLowerCase().startsWith(query.toLowerCase()));
|
|
86
|
+
log('getReferences', { query, res });
|
|
87
|
+
return res;
|
|
88
|
+
},
|
|
89
|
+
resolveReference: async ({ uri }) => {
|
|
90
|
+
const res = references.find((i) => i.uri === uri);
|
|
91
|
+
log('resolveReference', { uri, res });
|
|
92
|
+
return res ?? null;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension } from '@codemirror/state';
|
|
6
|
+
import React, { forwardRef, useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type ThemedClassName, useThemeContext } from '@dxos/react-ui';
|
|
9
|
+
import { Editor, type EditorController, type UseTextEditorProps } from '@dxos/react-ui-editor';
|
|
10
|
+
import {
|
|
11
|
+
type BasicExtensionsOptions,
|
|
12
|
+
type SubmitOptions,
|
|
13
|
+
createBasicExtensions,
|
|
14
|
+
createThemeExtensions,
|
|
15
|
+
submit,
|
|
16
|
+
} from '@dxos/ui-editor';
|
|
17
|
+
import { isTruthy } from '@dxos/util';
|
|
18
|
+
|
|
19
|
+
import { type ReferencesOptions } from './references';
|
|
20
|
+
|
|
21
|
+
export interface ChatEditorController extends EditorController {}
|
|
22
|
+
|
|
23
|
+
export type ChatEditorProps = ThemedClassName<
|
|
24
|
+
{
|
|
25
|
+
extensions?: Extension;
|
|
26
|
+
references?: ReferencesOptions;
|
|
27
|
+
} & (SubmitOptions &
|
|
28
|
+
Pick<UseTextEditorProps, 'id' | 'autoFocus'> &
|
|
29
|
+
Pick<BasicExtensionsOptions, 'lineWrapping' | 'placeholder'>)
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
export const useChatExtensions = ({ extensions, lineWrapping = false, placeholder, onSubmit }: ChatEditorProps) => {
|
|
33
|
+
const { themeMode } = useThemeContext();
|
|
34
|
+
return useMemo<Extension[]>(
|
|
35
|
+
() =>
|
|
36
|
+
[
|
|
37
|
+
createThemeExtensions({ themeMode }),
|
|
38
|
+
createBasicExtensions({ bracketMatching: false, lineWrapping, placeholder }),
|
|
39
|
+
submit({ onSubmit }),
|
|
40
|
+
extensions,
|
|
41
|
+
].filter(isTruthy),
|
|
42
|
+
[themeMode, extensions, onSubmit],
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ChatEditor = forwardRef<ChatEditorController, ChatEditorProps>(
|
|
47
|
+
({ classNames, autoFocus, extensions: extensionsProp, lineWrapping = false, placeholder, onSubmit }, forwardRef) => {
|
|
48
|
+
const extensions = useChatExtensions({ extensions: extensionsProp, lineWrapping, placeholder, onSubmit });
|
|
49
|
+
|
|
50
|
+
// TODO(burdon): Popover.
|
|
51
|
+
return (
|
|
52
|
+
<Editor.Root ref={forwardRef}>
|
|
53
|
+
<Editor.Content classNames={classNames} autoFocus={autoFocus} extensions={extensions} />
|
|
54
|
+
</Editor.Root>
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type CompletionResult, autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
|
6
|
+
import { type Extension, RangeSet } from '@codemirror/state';
|
|
7
|
+
import {
|
|
8
|
+
Decoration,
|
|
9
|
+
type DecorationSet,
|
|
10
|
+
EditorView,
|
|
11
|
+
ViewPlugin,
|
|
12
|
+
type ViewUpdate,
|
|
13
|
+
WidgetType,
|
|
14
|
+
keymap,
|
|
15
|
+
} from '@codemirror/view';
|
|
16
|
+
|
|
17
|
+
import { Mutex } from '@dxos/async';
|
|
18
|
+
import { invariant } from '@dxos/invariant';
|
|
19
|
+
|
|
20
|
+
// TODO(dmaretskyi): Consider adding details renderer for when you hover over the reference.
|
|
21
|
+
export type ReferenceData = {
|
|
22
|
+
uri: string; // TODO(burdon): Rename dxn.
|
|
23
|
+
label: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @deprecated
|
|
28
|
+
*/
|
|
29
|
+
// TODO(burdon): Remove.
|
|
30
|
+
export interface ReferencesProvider {
|
|
31
|
+
getReferences({ query }: { query: string }): Promise<ReferenceData[]>;
|
|
32
|
+
resolveReference({ uri }: { uri: string }): Promise<ReferenceData | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ReferencesOptions = {
|
|
36
|
+
provider: ReferencesProvider;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @default '@'
|
|
40
|
+
*/
|
|
41
|
+
triggerCharacter?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Prevent the autocomplete from closing when the user blurs the editor.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
debug?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lookup object references.
|
|
52
|
+
* @deprecated
|
|
53
|
+
*/
|
|
54
|
+
// TODO(burdon): Remove.
|
|
55
|
+
export const references = ({ provider, triggerCharacter = '@', debug = false }: ReferencesOptions): Extension => {
|
|
56
|
+
invariant(triggerCharacter.length === 1);
|
|
57
|
+
|
|
58
|
+
const decorationField = ViewPlugin.fromClass(
|
|
59
|
+
class ReferenceView {
|
|
60
|
+
// TODO(burdon): Why?
|
|
61
|
+
private _mutex = new Mutex();
|
|
62
|
+
|
|
63
|
+
decorations: DecorationSet = Decoration.set([]);
|
|
64
|
+
|
|
65
|
+
constructor(view: EditorView) {
|
|
66
|
+
queueMicrotask(async () => {
|
|
67
|
+
const guard = await this._mutex.acquire();
|
|
68
|
+
try {
|
|
69
|
+
this.decorations = await this._computeDecorations(view);
|
|
70
|
+
} finally {
|
|
71
|
+
guard.release();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
update(update: ViewUpdate) {
|
|
77
|
+
if (update.docChanged) {
|
|
78
|
+
queueMicrotask(async () => {
|
|
79
|
+
const guard = await this._mutex.acquire();
|
|
80
|
+
try {
|
|
81
|
+
this.decorations = await this._computeDecorations(update.view);
|
|
82
|
+
} finally {
|
|
83
|
+
guard.release();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async _computeDecorations(view: EditorView): Promise<DecorationSet> {
|
|
90
|
+
const text = view.state.doc.toString();
|
|
91
|
+
const references = text.matchAll(new RegExp(`${triggerCharacter}[a-zA-Z0-9@:]+\\s`, 'g'));
|
|
92
|
+
|
|
93
|
+
const decorations = [];
|
|
94
|
+
for (const match of references) {
|
|
95
|
+
const reference = match[0];
|
|
96
|
+
const uri = reference.slice(1, -1);
|
|
97
|
+
const data = await provider.resolveReference({ uri });
|
|
98
|
+
if (data) {
|
|
99
|
+
decorations.push(
|
|
100
|
+
Decoration.replace({
|
|
101
|
+
widget: new ReferenceWidget(data),
|
|
102
|
+
}).range(match.index!, match.index! + reference.length),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Decoration.set(decorations);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
decorations: (v) => v.decorations,
|
|
112
|
+
provide: () => [
|
|
113
|
+
EditorView.atomicRanges.of(
|
|
114
|
+
(view): DecorationSet => view.plugin(decorationField)?.decorations ?? RangeSet.empty,
|
|
115
|
+
),
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
decorationField,
|
|
122
|
+
|
|
123
|
+
EditorView.theme({
|
|
124
|
+
'.cm-reference-pill': {
|
|
125
|
+
borderRadius: '0.25rem',
|
|
126
|
+
borderWidth: '1px',
|
|
127
|
+
marginRight: '0.25rem',
|
|
128
|
+
marginLeft: '0.25rem',
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
|
|
132
|
+
autocompletion({
|
|
133
|
+
tooltipClass: () => 'shadow rounded',
|
|
134
|
+
activateOnTyping: true,
|
|
135
|
+
aboveCursor: true,
|
|
136
|
+
closeOnBlur: !debug,
|
|
137
|
+
override: [
|
|
138
|
+
async (context): Promise<CompletionResult | null> => {
|
|
139
|
+
const match = context.matchBefore(new RegExp(`${triggerCharacter}[a-zA-Z0-9]+`));
|
|
140
|
+
if (!match || match?.to === match?.from) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const query = match.text.slice(1);
|
|
145
|
+
const references = await provider.getReferences({ query });
|
|
146
|
+
return {
|
|
147
|
+
from: match.from,
|
|
148
|
+
filter: false,
|
|
149
|
+
options: references.map((reference) => ({
|
|
150
|
+
label: reference.label,
|
|
151
|
+
apply: `${triggerCharacter}${reference.uri} `,
|
|
152
|
+
})),
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
|
|
158
|
+
keymap.of(completionKeymap),
|
|
159
|
+
];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
class ReferenceWidget extends WidgetType {
|
|
163
|
+
constructor(private data: ReferenceData) {
|
|
164
|
+
super();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
override ignoreEvent() {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override eq(other: this) {
|
|
172
|
+
return this.data.uri === other.data.uri;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
override toDOM() {
|
|
176
|
+
const span = document.createElement('span');
|
|
177
|
+
span.textContent = `@${this.data.label}`;
|
|
178
|
+
span.className = 'cm-reference-pill';
|
|
179
|
+
return span;
|
|
180
|
+
}
|
|
181
|
+
}
|