@dxos/react-ui-chat 0.10.0 → 0.11.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 (54) hide show
  1. package/dist/lib/index.mjs +414 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +7 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/types/src/components/ChatEditor/ChatEditor.d.ts +4 -1
  6. package/dist/types/src/components/ChatEditor/ChatEditor.d.ts.map +1 -1
  7. package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts +6 -2
  8. package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/ChatEditor/commands.d.ts +15 -0
  10. package/dist/types/src/components/ChatEditor/commands.d.ts.map +1 -0
  11. package/dist/types/src/components/ChatEditor/commands.test.d.ts +2 -0
  12. package/dist/types/src/components/ChatEditor/commands.test.d.ts.map +1 -0
  13. package/dist/types/src/components/ChatEditor/index.d.ts +2 -0
  14. package/dist/types/src/components/ChatEditor/index.d.ts.map +1 -1
  15. package/dist/types/src/components/ChatEditor/useChatExtensions.d.ts +4 -0
  16. package/dist/types/src/components/ChatEditor/useChatExtensions.d.ts.map +1 -0
  17. package/dist/types/src/components/ChatStatus/ChatStatus.d.ts +0 -8
  18. package/dist/types/src/components/ChatStatus/ChatStatus.d.ts.map +1 -1
  19. package/dist/types/src/components/ChatStatus/ChatStatus.stories.d.ts.map +1 -1
  20. package/dist/types/src/components/ChatStatus/ChatStatusContext.d.ts +10 -0
  21. package/dist/types/src/components/ChatStatus/ChatStatusContext.d.ts.map +1 -0
  22. package/dist/types/src/components/ChatStatus/index.d.ts +1 -0
  23. package/dist/types/src/components/ChatStatus/index.d.ts.map +1 -1
  24. package/dist/types/src/components/ChatStatusIndicator/ChatStatusIndicator.d.ts +10 -0
  25. package/dist/types/src/components/ChatStatusIndicator/ChatStatusIndicator.d.ts.map +1 -0
  26. package/dist/types/src/components/ChatStatusIndicator/index.d.ts +2 -0
  27. package/dist/types/src/components/ChatStatusIndicator/index.d.ts.map +1 -0
  28. package/dist/types/src/components/index.d.ts +1 -0
  29. package/dist/types/src/components/index.d.ts.map +1 -1
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +29 -31
  32. package/src/components/ChatEditor/ChatEditor.stories.tsx +83 -1
  33. package/src/components/ChatEditor/ChatEditor.tsx +19 -28
  34. package/src/components/ChatEditor/commands.test.ts +16 -0
  35. package/src/components/ChatEditor/commands.ts +59 -0
  36. package/src/components/ChatEditor/index.ts +3 -0
  37. package/src/components/ChatEditor/useChatExtensions.ts +51 -0
  38. package/src/components/ChatStatus/ChatStatus.stories.tsx +2 -1
  39. package/src/components/ChatStatus/ChatStatus.tsx +1 -16
  40. package/src/components/ChatStatus/ChatStatusContext.ts +21 -0
  41. package/src/components/ChatStatus/index.ts +2 -0
  42. package/src/components/ChatStatusIndicator/ChatStatusIndicator.tsx +45 -0
  43. package/src/components/ChatStatusIndicator/index.ts +5 -0
  44. package/src/components/index.ts +1 -0
  45. package/dist/lib/browser/index.mjs +0 -359
  46. package/dist/lib/browser/index.mjs.map +0 -7
  47. package/dist/lib/browser/meta.json +0 -1
  48. package/dist/lib/browser/translations.mjs +0 -14
  49. package/dist/lib/browser/translations.mjs.map +0 -7
  50. package/dist/lib/node-esm/index.mjs +0 -361
  51. package/dist/lib/node-esm/index.mjs.map +0 -7
  52. package/dist/lib/node-esm/meta.json +0 -1
  53. package/dist/lib/node-esm/translations.mjs +0 -16
  54. package/dist/lib/node-esm/translations.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-chat",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Chat components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -18,14 +18,12 @@
18
18
  ".": {
19
19
  "source": "./src/index.ts",
20
20
  "types": "./dist/types/src/index.d.ts",
21
- "browser": "./dist/lib/browser/index.mjs",
22
- "node": "./dist/lib/node-esm/index.mjs"
21
+ "import": "./dist/lib/index.mjs"
23
22
  },
24
23
  "./translations": {
25
24
  "source": "./src/translations.ts",
26
25
  "types": "./dist/types/src/translations.d.ts",
27
- "browser": "./dist/lib/browser/translations.mjs",
28
- "node": "./dist/lib/node-esm/translations.mjs"
26
+ "import": "./dist/lib/translations.mjs"
29
27
  }
30
28
  },
31
29
  "types": "dist/types/src/index.d.ts",
@@ -34,28 +32,28 @@
34
32
  "src"
35
33
  ],
36
34
  "dependencies": {
37
- "@codemirror/autocomplete": "^6.20.2",
38
- "@codemirror/language": "^6.12.3",
39
- "@codemirror/search": "^6.7.0",
40
- "@codemirror/state": "^6.6.0",
41
- "@codemirror/view": "^6.43.0",
35
+ "@codemirror/autocomplete": "^6.20.3",
36
+ "@codemirror/language": "^6.12.4",
37
+ "@codemirror/search": "^6.7.1",
38
+ "@codemirror/state": "^6.7.1",
39
+ "@codemirror/view": "^6.43.6",
42
40
  "@fluentui/react-tabster": "9.26.11",
43
41
  "@radix-ui/react-accordion": "1.2.3",
44
42
  "@radix-ui/react-context": "1.1.1",
45
- "@dxos/async": "0.10.0",
46
- "@dxos/debug": "0.10.0",
47
- "@dxos/invariant": "0.10.0",
48
- "@dxos/log": "0.10.0",
49
- "@dxos/react-ui": "0.10.0",
50
- "@dxos/react-ui-components": "0.10.0",
51
- "@dxos/react-ui-dnd": "0.10.0",
52
- "@dxos/react-ui-syntax-highlighter": "0.10.0",
53
- "@dxos/react-ui-editor": "0.10.0",
54
- "@dxos/ui-editor": "0.10.0",
55
- "@dxos/ui-theme": "0.10.0",
56
- "@dxos/ui-types": "0.10.0",
57
- "@dxos/util": "0.10.0",
58
- "@dxos/echo": "0.10.0"
43
+ "@dxos/async": "0.11.1",
44
+ "@dxos/debug": "0.11.1",
45
+ "@dxos/echo": "0.11.1",
46
+ "@dxos/invariant": "0.11.1",
47
+ "@dxos/log": "0.11.1",
48
+ "@dxos/react-ui": "0.11.1",
49
+ "@dxos/react-ui-components": "0.11.1",
50
+ "@dxos/react-ui-editor": "0.11.1",
51
+ "@dxos/react-ui-syntax-highlighter": "0.11.1",
52
+ "@dxos/react-ui-dnd": "0.11.1",
53
+ "@dxos/ui-editor": "0.11.1",
54
+ "@dxos/ui-theme": "0.11.1",
55
+ "@dxos/ui-types": "0.11.1",
56
+ "@dxos/util": "0.11.1"
59
57
  },
60
58
  "devDependencies": {
61
59
  "@types/react": "~19.2.17",
@@ -63,18 +61,18 @@
63
61
  "effect": "3.21.4",
64
62
  "react": "~19.2.7",
65
63
  "react-dom": "~19.2.7",
66
- "vite": "^8.0.16",
67
- "@dxos/random": "0.10.0",
68
- "@dxos/echo": "0.10.0",
69
- "@dxos/react-client": "0.10.0",
70
- "@dxos/storybook-utils": "0.10.0"
64
+ "vite": "^8.1.4",
65
+ "@dxos/echo": "0.11.1",
66
+ "@dxos/random": "0.11.1",
67
+ "@dxos/react-client": "0.11.1",
68
+ "@dxos/storybook-utils": "0.11.1"
71
69
  },
72
70
  "peerDependencies": {
73
71
  "effect": "3.21.4",
74
72
  "react": "~19.2.7",
75
73
  "react-dom": "~19.2.7",
76
- "@dxos/ui-theme": "0.10.0",
77
- "@dxos/react-ui": "0.10.0"
74
+ "@dxos/react-ui": "^0.11.1",
75
+ "@dxos/ui-theme": "^0.11.1"
78
76
  },
79
77
  "publishConfig": {
80
78
  "access": "public"
@@ -3,13 +3,16 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo } from 'react';
7
+ import { expect, userEvent, waitFor } from 'storybook/test';
6
8
 
7
9
  import { log } from '@dxos/log';
8
10
  import { withTheme } from '@dxos/react-ui/testing';
9
11
 
10
12
  import { translations } from '#translations';
11
13
 
12
- import { ChatEditor } from './ChatEditor';
14
+ import { ChatEditor, type ChatEditorProps } from './ChatEditor';
15
+ import { type CommandData, commands } from './commands';
13
16
  import { type ReferenceData } from './references';
14
17
 
15
18
  const meta = {
@@ -35,6 +38,14 @@ export const Default: Story = {
35
38
  },
36
39
  };
37
40
 
41
+ export const Markdown: Story = {
42
+ args: {
43
+ classNames,
44
+ markdown: true,
45
+ placeholder: 'Type **markdown**...',
46
+ },
47
+ };
48
+
38
49
  // TODO(burdon): Restore and reconcile with suggest/typeahead.
39
50
  export const WithSuggestions: Story = {
40
51
  args: {
@@ -95,3 +106,74 @@ export const WithReferences: Story = {
95
106
  },
96
107
  },
97
108
  };
109
+
110
+ const projectCommands: CommandData[] = [
111
+ { sentinel: '$track', description: 'Record a follow-up task' },
112
+ { sentinel: '$hydrate', description: 'Checkpoint project state' },
113
+ ];
114
+
115
+ // Reset per play run (module re-imports fresh on a vitest retry, so this doesn't leak across tests).
116
+ let submittedTexts: string[] = [];
117
+
118
+ // The `commands` extension isn't JSON-serializable, so it's built in `render` rather than passed
119
+ // through `args` (Storybook's controls addon warns on cyclic arg values).
120
+ const WithCommandsRender = (args: ChatEditorProps) => {
121
+ const extensions = useMemo(() => commands({ getCommands: () => projectCommands }), []);
122
+ return (
123
+ <ChatEditor
124
+ {...args}
125
+ extensions={extensions}
126
+ onSubmit={(text) => {
127
+ submittedTexts.push(text);
128
+ return true;
129
+ }}
130
+ />
131
+ );
132
+ };
133
+
134
+ export const WithCommands: Story = {
135
+ render: WithCommandsRender,
136
+ args: {
137
+ classNames,
138
+ placeholder: 'Type $ for commands...',
139
+ },
140
+ play: async ({ canvasElement }) => {
141
+ submittedTexts = [];
142
+
143
+ const content = await waitFor(() => {
144
+ const element = canvasElement.querySelector<HTMLElement>('.cm-content');
145
+ if (!element) {
146
+ throw new Error('Chat editor content not found.');
147
+ }
148
+ return element;
149
+ });
150
+
151
+ await userEvent.click(content);
152
+ await userEvent.type(content, '$t');
153
+
154
+ // The sentinel-command popover lists only prefix matches for the typed token. The matched
155
+ // prefix renders in its own span, so read the option label's full text rather than matching text nodes.
156
+ const optionLabels = () =>
157
+ Array.from(canvasElement.querySelectorAll('.cm-completionLabel')).map((node) => node.textContent);
158
+ await waitFor(() => expect(optionLabels()).toEqual(['$track']));
159
+
160
+ // `acceptCompletion` ignores Enter within `interactionDelay` (75ms, default) of the popover
161
+ // opening — an anti-flicker guard against an Enter that was already in flight before the
162
+ // options rendered. Real usage always clears this; wait it out so the test presses Enter the
163
+ // way a person would (after seeing the popover), not mid-guard.
164
+ await new Promise((resolve) => setTimeout(resolve, 150));
165
+
166
+ // Regression: `submit()`'s Enter binding must defer to the open completion popover (both sit at
167
+ // `Prec.highest`) rather than swallowing Enter and submitting the raw "$t" text.
168
+ await userEvent.keyboard('{Enter}');
169
+ await waitFor(() => expect(content.textContent).toEqual('$track'));
170
+ void expect(submittedTexts).toEqual([]);
171
+
172
+ // Enter with no completion open still submits and resets the editor (submit's own contract).
173
+ // `userEvent.keyboard` (unlike `userEvent.type(content, ...)`) drives `document.activeElement`
174
+ // directly, so it respects CodeMirror's actual cursor position instead of assuming one.
175
+ await userEvent.keyboard(`${'{Backspace}'.repeat('$track'.length)}hello{Enter}`);
176
+ await waitFor(() => expect(submittedTexts).toEqual(['hello']));
177
+ await waitFor(() => expect(canvasElement.querySelector('.cm-placeholder')).not.toBeNull());
178
+ },
179
+ };
@@ -3,21 +3,14 @@
3
3
  //
4
4
 
5
5
  import { type Extension } from '@codemirror/state';
6
- import React, { forwardRef, useMemo } from 'react';
6
+ import React, { forwardRef } from 'react';
7
7
 
8
- import { type ThemedClassName, useThemeContext } from '@dxos/react-ui';
8
+ import { type ThemedClassName } from '@dxos/react-ui';
9
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
- xmlFormatting,
17
- } from '@dxos/ui-editor';
18
- import { isTruthy } from '@dxos/util';
10
+ import { type BasicExtensionsOptions, type SubmitOptions } from '@dxos/ui-editor';
19
11
 
20
12
  import { type ReferencesOptions } from './references';
13
+ import { useChatExtensions } from './useChatExtensions';
21
14
 
22
15
  export interface ChatEditorController extends EditorController {}
23
16
 
@@ -25,29 +18,27 @@ export type ChatEditorProps = ThemedClassName<
25
18
  {
26
19
  extensions?: Extension;
27
20
  references?: ReferencesOptions;
21
+ /** Enable inline markdown formatting (decoration, syntax highlighting, and formatting shortcuts). */
22
+ markdown?: boolean;
28
23
  } & (SubmitOptions &
29
24
  Pick<UseTextEditorProps, 'id' | 'autoFocus'> &
30
25
  Pick<BasicExtensionsOptions, 'lineWrapping' | 'placeholder'>)
31
26
  >;
32
27
 
33
- export const useChatExtensions = ({ extensions, lineWrapping = false, placeholder, onSubmit }: ChatEditorProps) => {
34
- const { themeMode } = useThemeContext();
35
- return useMemo<Extension[]>(
36
- () =>
37
- [
38
- createThemeExtensions({ themeMode }),
39
- createBasicExtensions({ bracketMatching: false, lineWrapping, placeholder }),
40
- xmlFormatting(),
41
- submit({ onSubmit }),
42
- extensions,
43
- ].filter(isTruthy),
44
- [themeMode, extensions, onSubmit],
45
- );
46
- };
47
-
48
28
  export const ChatEditor = forwardRef<ChatEditorController, ChatEditorProps>(
49
- ({ classNames, autoFocus, extensions: extensionsProp, lineWrapping = false, placeholder, onSubmit }, forwardRef) => {
50
- const extensions = useChatExtensions({ extensions: extensionsProp, lineWrapping, placeholder, onSubmit });
29
+ (
30
+ {
31
+ classNames,
32
+ autoFocus,
33
+ extensions: extensionsProp,
34
+ markdown = false,
35
+ lineWrapping = false,
36
+ placeholder,
37
+ onSubmit,
38
+ },
39
+ forwardRef,
40
+ ) => {
41
+ const extensions = useChatExtensions({ extensions: extensionsProp, markdown, lineWrapping, placeholder, onSubmit });
51
42
 
52
43
  // TODO(burdon): Popover.
53
44
  return (
@@ -0,0 +1,16 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { matchCommands } from './commands';
8
+
9
+ describe('commands completion', () => {
10
+ test('prefix-matches sentinels', ({ expect }) => {
11
+ const all = [{ sentinel: '$track' }, { sentinel: '$resume' }];
12
+ expect(matchCommands(all, '$t').map((c) => c.sentinel)).toEqual(['$track']);
13
+ expect(matchCommands(all, '$')).toHaveLength(2);
14
+ expect(matchCommands(all, 'tr')).toHaveLength(0);
15
+ });
16
+ });
@@ -0,0 +1,59 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import {
6
+ type CompletionContext,
7
+ type CompletionResult,
8
+ acceptCompletion,
9
+ autocompletion,
10
+ completionStatus,
11
+ } from '@codemirror/autocomplete';
12
+ import { type Extension, Prec } from '@codemirror/state';
13
+ import { keymap } from '@codemirror/view';
14
+
15
+ export type CommandData = { sentinel: string; description?: string };
16
+
17
+ /** Pure prefix matcher, exported for tests. */
18
+ export const matchCommands = (all: CommandData[], token: string): CommandData[] =>
19
+ token.startsWith('$') ? all.filter(({ sentinel }) => sentinel.startsWith(token)) : [];
20
+
21
+ export type CommandsOptions = { getCommands: () => CommandData[] };
22
+
23
+ /**
24
+ * Sentinel-command completion: typing `$` offers the commands defined by the bound context's instructions.
25
+ */
26
+ export const commands = ({ getCommands }: CommandsOptions): Extension => [
27
+ autocompletion({
28
+ override: [
29
+ (context: CompletionContext): CompletionResult | null => {
30
+ const word = context.matchBefore(/\$[\w-]*/);
31
+ if (!word || (word.from === word.to && !context.explicit)) {
32
+ return null;
33
+ }
34
+ const options = matchCommands(getCommands(), context.state.sliceDoc(word.from, word.to));
35
+ if (options.length === 0) {
36
+ return null;
37
+ }
38
+ return {
39
+ from: word.from,
40
+ options: options.map(({ sentinel, description }) => ({ label: sentinel, detail: description })),
41
+ };
42
+ },
43
+ ],
44
+ }),
45
+ // The host editor's `submit()` extension also binds Enter at `Prec.highest`; ties between equal
46
+ // precedences break by extension order, so the caller must place this extension ahead of `submit()`.
47
+ // `completionStatus`/`acceptCompletion` are imported from the same module as `autocompletion()` above
48
+ // (not re-derived in a different package) so this always reads the completion state `autocompletion()`
49
+ // actually wrote — a cross-package import of the same-named API can resolve to a distinct bundled
50
+ // copy of `@codemirror/autocomplete` under some bundler configurations, silently reading `null`.
51
+ Prec.highest(
52
+ keymap.of([
53
+ {
54
+ key: 'Enter',
55
+ run: (view) => (completionStatus(view.state) === 'active' ? acceptCompletion(view) : false),
56
+ },
57
+ ]),
58
+ ),
59
+ ];
@@ -2,6 +2,9 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export * from './commands';
5
6
  export * from './references';
6
7
 
8
+ export * from './useChatExtensions';
9
+
7
10
  export * from './ChatEditor';
@@ -0,0 +1,51 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { useMemo } from 'react';
7
+
8
+ import { useThemeContext } from '@dxos/react-ui';
9
+ import {
10
+ createBasicExtensions,
11
+ createMarkdownExtensions,
12
+ createThemeExtensions,
13
+ decorateMarkdown,
14
+ formattingKeymap,
15
+ submit,
16
+ xmlFormatting,
17
+ } from '@dxos/ui-editor';
18
+ import { isTruthy } from '@dxos/util';
19
+
20
+ import { type ChatEditorProps } from './ChatEditor';
21
+
22
+ // Kept out of `ChatEditor.tsx`: react-refresh only fast-refreshes a module whose
23
+ // exports are all components, so values exported beside them force a full page reload on
24
+ // every edit.
25
+
26
+ export const useChatExtensions = ({
27
+ extensions,
28
+ markdown = false,
29
+ lineWrapping = false,
30
+ placeholder,
31
+ onSubmit,
32
+ }: ChatEditorProps) => {
33
+ const { themeMode } = useThemeContext();
34
+ return useMemo<Extension[]>(
35
+ () =>
36
+ [
37
+ createThemeExtensions({ themeMode, syntaxHighlighting: markdown }),
38
+ createBasicExtensions({ bracketMatching: false, lineWrapping, placeholder }),
39
+ xmlFormatting(),
40
+ markdown && [createMarkdownExtensions(), decorateMarkdown(), formattingKeymap()],
41
+ // Caller extensions (e.g. `commands()`'s completion-aware Enter binding) must precede
42
+ // `submit()`: both bind Enter at `Prec.highest`, and CodeMirror breaks precedence ties by
43
+ // extension order, so listing `submit()` first would always win and swallow the keystroke.
44
+ extensions,
45
+ submit({ onSubmit }),
46
+ ]
47
+ .flat()
48
+ .filter(isTruthy),
49
+ [themeMode, markdown, lineWrapping, placeholder, extensions, onSubmit],
50
+ );
51
+ };
@@ -9,7 +9,8 @@ import { Button, Toolbar } from '@dxos/react-ui';
9
9
  import { Matrix } from '@dxos/react-ui-components';
10
10
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
11
 
12
- import { ChatStatus, type ChatStatusController, useChatStatusContext } from './ChatStatus';
12
+ import { ChatStatus, type ChatStatusController } from './ChatStatus';
13
+ import { useChatStatusContext } from './ChatStatusContext';
13
14
 
14
15
  const meta = {
15
16
  title: 'ui/react-ui-chat/ChatStatus',
@@ -2,31 +2,16 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { createContext } from '@radix-ui/react-context';
6
5
  import React, { type PropsWithChildren, type Ref, forwardRef, useEffect, useImperativeHandle, useState } from 'react';
7
6
 
8
7
  import { type ThemedClassName } from '@dxos/react-ui';
9
8
  import { mx } from '@dxos/ui-theme';
10
9
 
10
+ import { ChatStatusProvider, useChatStatusContext } from './ChatStatusContext';
11
11
  import { formatElapsed } from './format';
12
12
 
13
13
  const TICK_MS = 1_000;
14
14
 
15
- //
16
- // Context
17
- //
18
-
19
- type ChatStatusContextValue = {
20
- /** Whole seconds elapsed since ChatStatus.Root mounted. Only advances while `running` is true. */
21
- elapsed: number;
22
- /** Whether the ChatStatus.Root tick is currently active. Toggled via the ChatStatusController. */
23
- running: boolean;
24
- };
25
-
26
- const [ChatStatusProvider, useChatStatusContext] = createContext<ChatStatusContextValue>('ChatStatus');
27
-
28
- export { useChatStatusContext };
29
-
30
15
  //
31
16
  // Controller
32
17
  //
@@ -0,0 +1,21 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+
7
+ // Kept out of `ChatStatus.tsx`: react-refresh only fast-refreshes a module whose exports are all
8
+ // components, so a context and its hook exported beside them force a full page reload on every edit.
9
+
10
+ //
11
+ // Context
12
+ //
13
+
14
+ export type ChatStatusContextValue = {
15
+ /** Whole seconds elapsed since ChatStatus.Root mounted. Only advances while `running` is true. */
16
+ elapsed: number;
17
+ /** Whether the ChatStatus.Root tick is currently active. Toggled via the ChatStatusController. */
18
+ running: boolean;
19
+ };
20
+
21
+ export const [ChatStatusProvider, useChatStatusContext] = createContext<ChatStatusContextValue>('ChatStatus');
@@ -2,5 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export { useChatStatusContext } from './ChatStatusContext';
6
+
5
7
  export * from './ChatStatus';
6
8
  export * from './format';
@@ -0,0 +1,45 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useState } from 'react';
6
+
7
+ import { type ThemedClassName, Tooltip, useTimeout } from '@dxos/react-ui';
8
+ // Leaf import (not the package barrel): the barrel re-exports QueryEditor/QueryForm, which pull the
9
+ // heavy @dxos/ai + @dxos/echo-query stack (tiktoken wasm, etc.) — inappropriate for lean consumers
10
+ // like the browser extension. Importing the Spinner leaf keeps that graph out.
11
+ import { Spinner, type SpinnerProps } from '@dxos/react-ui-components/Spinner';
12
+ import { mx } from '@dxos/ui-theme';
13
+
14
+ const period = 3_000;
15
+
16
+ export type ChatStatusIndicatorProps = ThemedClassName<
17
+ {
18
+ preset?: string;
19
+ processing?: boolean;
20
+ error?: Error;
21
+ } & Pick<SpinnerProps, 'size' | 'onClick'>
22
+ >;
23
+
24
+ export const ChatStatusIndicator = ({ classNames, preset, processing, error, ...props }: ChatStatusIndicatorProps) => {
25
+ const [init, setInit] = useState(false);
26
+ useEffect(() => setInit(false), [preset]);
27
+ useTimeout(
28
+ async () => {
29
+ setInit(true);
30
+ },
31
+ period / 2,
32
+ [preset],
33
+ );
34
+
35
+ return (
36
+ <div className={mx('relative flex', classNames)}>
37
+ <Spinner duration={period} state={!init ? 'flash' : error ? 'error' : processing ? 'spin' : 'pulse'} {...props} />
38
+ {error && (
39
+ <Tooltip.Trigger asChild content={error.message}>
40
+ <div className='absolute inset-0' />
41
+ </Tooltip.Trigger>
42
+ )}
43
+ </div>
44
+ );
45
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ChatStatusIndicator';
@@ -5,3 +5,4 @@
5
5
  export * from './ChatDialog';
6
6
  export * from './ChatEditor';
7
7
  export * from './ChatStatus';
8
+ export * from './ChatStatusIndicator';