@dxos/react-ui-chat 0.8.4-main.1068cf700f → 0.8.4-main.1c7ec43d41

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 (44) hide show
  1. package/dist/lib/browser/index.mjs +103 -31
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +14 -0
  5. package/dist/lib/browser/translations.mjs.map +7 -0
  6. package/dist/lib/node-esm/index.mjs +103 -31
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/translations.mjs +16 -0
  10. package/dist/lib/node-esm/translations.mjs.map +7 -0
  11. package/dist/types/src/components/ChatDialog/ChatDialog.d.ts +16 -0
  12. package/dist/types/src/components/ChatDialog/ChatDialog.d.ts.map +1 -1
  13. package/dist/types/src/components/ChatDialog/ChatDialog.stories.d.ts.map +1 -1
  14. package/dist/types/src/components/ChatEditor/ChatEditor.d.ts.map +1 -1
  15. package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts.map +1 -1
  16. package/dist/types/src/components/ChatEditor/references.d.ts.map +1 -1
  17. package/dist/types/src/components/ChatStatus/ChatStatus.d.ts +40 -0
  18. package/dist/types/src/components/ChatStatus/ChatStatus.d.ts.map +1 -0
  19. package/dist/types/src/components/ChatStatus/ChatStatus.stories.d.ts +27 -0
  20. package/dist/types/src/components/ChatStatus/ChatStatus.stories.d.ts.map +1 -0
  21. package/dist/types/src/components/ChatStatus/format.d.ts +7 -0
  22. package/dist/types/src/components/ChatStatus/format.d.ts.map +1 -0
  23. package/dist/types/src/components/ChatStatus/format.test.d.ts +2 -0
  24. package/dist/types/src/components/ChatStatus/format.test.d.ts.map +1 -0
  25. package/dist/types/src/components/ChatStatus/index.d.ts +3 -0
  26. package/dist/types/src/components/ChatStatus/index.d.ts.map +1 -0
  27. package/dist/types/src/components/index.d.ts +1 -0
  28. package/dist/types/src/components/index.d.ts.map +1 -1
  29. package/dist/types/src/index.d.ts +0 -1
  30. package/dist/types/src/index.d.ts.map +1 -1
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +36 -30
  33. package/src/components/ChatDialog/ChatDialog.stories.tsx +3 -3
  34. package/src/components/ChatDialog/ChatDialog.tsx +12 -8
  35. package/src/components/ChatEditor/ChatEditor.stories.tsx +4 -4
  36. package/src/components/ChatEditor/ChatEditor.tsx +3 -1
  37. package/src/components/ChatEditor/references.ts +1 -1
  38. package/src/components/ChatStatus/ChatStatus.stories.tsx +130 -0
  39. package/src/components/ChatStatus/ChatStatus.tsx +183 -0
  40. package/src/components/ChatStatus/format.test.ts +48 -0
  41. package/src/components/ChatStatus/format.ts +27 -0
  42. package/src/components/ChatStatus/index.ts +6 -0
  43. package/src/components/index.ts +1 -0
  44. package/src/index.ts +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-chat",
3
- "version": "0.8.4-main.1068cf700f",
3
+ "version": "0.8.4-main.1c7ec43d41",
4
4
  "description": "Chat components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -11,18 +11,24 @@
11
11
  "license": "MIT",
12
12
  "author": "DXOS.org",
13
13
  "type": "module",
14
+ "imports": {
15
+ "#translations": "./src/translations.ts"
16
+ },
14
17
  "exports": {
15
18
  ".": {
16
19
  "source": "./src/index.ts",
17
20
  "types": "./dist/types/src/index.d.ts",
18
21
  "browser": "./dist/lib/browser/index.mjs",
19
22
  "node": "./dist/lib/node-esm/index.mjs"
23
+ },
24
+ "./translations": {
25
+ "source": "./src/translations.ts",
26
+ "types": "./dist/types/src/translations.d.ts",
27
+ "browser": "./dist/lib/browser/translations.mjs",
28
+ "node": "./dist/lib/node-esm/translations.mjs"
20
29
  }
21
30
  },
22
31
  "types": "dist/types/src/index.d.ts",
23
- "typesVersions": {
24
- "*": {}
25
- },
26
32
  "files": [
27
33
  "dist",
28
34
  "src"
@@ -32,45 +38,45 @@
32
38
  "@codemirror/language": "^6.11.3",
33
39
  "@codemirror/search": "^6.5.11",
34
40
  "@codemirror/state": "^6.5.2",
35
- "@codemirror/view": "^6.38.4",
41
+ "@codemirror/view": "^6.38.5",
36
42
  "@fluentui/react-tabster": "9.26.11",
37
43
  "@radix-ui/react-accordion": "1.2.3",
38
44
  "@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"
45
+ "@dxos/debug": "0.8.4-main.1c7ec43d41",
46
+ "@dxos/async": "0.8.4-main.1c7ec43d41",
47
+ "@dxos/react-ui": "0.8.4-main.1c7ec43d41",
48
+ "@dxos/react-ui-components": "0.8.4-main.1c7ec43d41",
49
+ "@dxos/echo": "0.8.4-main.1c7ec43d41",
50
+ "@dxos/log": "0.8.4-main.1c7ec43d41",
51
+ "@dxos/react-ui-dnd": "0.8.4-main.1c7ec43d41",
52
+ "@dxos/react-ui-editor": "0.8.4-main.1c7ec43d41",
53
+ "@dxos/invariant": "0.8.4-main.1c7ec43d41",
54
+ "@dxos/react-ui-sfx": "0.8.4-main.1c7ec43d41",
55
+ "@dxos/react-ui-syntax-highlighter": "0.8.4-main.1c7ec43d41",
56
+ "@dxos/ui-editor": "0.8.4-main.1c7ec43d41",
57
+ "@dxos/ui-theme": "0.8.4-main.1c7ec43d41",
58
+ "@dxos/react-ui-text-tooltip": "0.8.4-main.1c7ec43d41",
59
+ "@dxos/util": "0.8.4-main.1c7ec43d41",
60
+ "@dxos/ui-types": "0.8.4-main.1c7ec43d41"
55
61
  },
56
62
  "devDependencies": {
57
63
  "@types/react": "~19.2.7",
58
64
  "@types/react-dom": "~19.2.3",
59
- "effect": "3.19.16",
65
+ "effect": "3.20.0",
60
66
  "react": "~19.2.3",
61
67
  "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"
68
+ "vite": "^8.0.10",
69
+ "@dxos/echo": "0.8.4-main.1c7ec43d41",
70
+ "@dxos/random": "0.8.4-main.1c7ec43d41",
71
+ "@dxos/storybook-utils": "0.8.4-main.1c7ec43d41",
72
+ "@dxos/react-client": "0.8.4-main.1c7ec43d41"
67
73
  },
68
74
  "peerDependencies": {
69
- "effect": "3.19.16",
75
+ "effect": "3.20.0",
70
76
  "react": "~19.2.3",
71
77
  "react-dom": "~19.2.3",
72
- "@dxos/react-ui": "0.8.4-main.1068cf700f",
73
- "@dxos/ui-theme": "0.8.4-main.1068cf700f"
78
+ "@dxos/react-ui": "0.8.4-main.1c7ec43d41",
79
+ "@dxos/ui-theme": "0.8.4-main.1c7ec43d41"
74
80
  },
75
81
  "publishConfig": {
76
82
  "access": "public"
@@ -8,7 +8,7 @@ import React, { useState } from 'react';
8
8
  import { Input, Toolbar } from '@dxos/react-ui';
9
9
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
10
 
11
- import { translations } from '../../translations';
11
+ import { translations } from '#translations';
12
12
 
13
13
  import { ChatDialog } from './ChatDialog';
14
14
 
@@ -37,12 +37,12 @@ const meta = {
37
37
  <ChatDialog.Header title='Chat' />
38
38
  <ChatDialog.Content>
39
39
  {items.map((item) => (
40
- <div key={item} className='pis-4 pbe-1'>
40
+ <div key={item} className='ps-4 pb-1'>
41
41
  {item}
42
42
  </div>
43
43
  ))}
44
44
  </ChatDialog.Content>
45
- <ChatDialog.Footer classNames='pli-2 items-center'>
45
+ <ChatDialog.Footer classNames='px-2 items-center'>
46
46
  <Input.Root>
47
47
  <Input.TextInput classNames='border-none' placeholder='Test' />
48
48
  </Input.Root>
@@ -14,7 +14,9 @@ const preventDefault = (event: Event) => event.preventDefault();
14
14
  // TODO(burdon): Factor out.
15
15
  const Endcap = ({ children }: PropsWithChildren) => {
16
16
  return (
17
- <div className='grid is-[var(--rail-action)] bs-[var(--rail-action)] items-center justify-center'>{children}</div>
17
+ <div className='grid w-[var(--dx-rail-action)] h-[var(--dx-rail-action)] items-center justify-center'>
18
+ {children}
19
+ </div>
18
20
  );
19
21
  };
20
22
 
@@ -71,10 +73,10 @@ const ChatDialogRoot = ({
71
73
  <Dialog.Root modal={false} open={open} onOpenChange={setOpen}>
72
74
  <div role='none' className='dx-dialog__overlay bg-transparent pointer-events-none' data-block-align='end'>
73
75
  <Dialog.Content
76
+ size='md'
74
77
  inOverlayLayout
75
78
  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',
79
+ 'grid grid-rows-[var(--dx-rail-action)_1fr_min-content] p-0 overflow-hidden box-content pointer-events-auto',
78
80
  ]}
79
81
  onEscapeKeyDown={onEscape}
80
82
  onInteractOutside={preventDefault}
@@ -103,14 +105,16 @@ const ChatDialogHeader = ({ classNames, title }: ChatDialogHeaderProps) => {
103
105
  const { expanded, setExpanded } = useChatDialogContext(CHAT_DIALOG_HEADER_NAME);
104
106
 
105
107
  return (
106
- <div className={mx('grid grid-cols-[var(--rail-action)_1fr_min-content] items-center overflow-hidden', classNames)}>
108
+ <div
109
+ className={mx('grid grid-cols-[var(--dx-rail-action)_1fr_min-content] items-center overflow-hidden', classNames)}
110
+ >
107
111
  <Endcap>
108
112
  <Dialog.Close>
109
113
  <Icon icon='ph--x--regular' />
110
114
  </Dialog.Close>
111
115
  </Endcap>
112
116
  <Dialog.Title
113
- classNames='flex is-full justify-center text-sm text-subdued select-none cursor-pointer'
117
+ classNames='flex w-full justify-center text-sm text-subdued select-none cursor-pointer'
114
118
  onClick={() => setExpanded((expanded) => !expanded)}
115
119
  >
116
120
  {title}
@@ -120,7 +124,7 @@ const ChatDialogHeader = ({ classNames, title }: ChatDialogHeaderProps) => {
120
124
  variant='ghost'
121
125
  icon='ph--caret-up--regular'
122
126
  iconOnly
123
- classNames={mx('!p-1 [&>svg]:transition [&>svg]:duration-200', expanded && '[&>svg]:rotate-180')}
127
+ classNames={mx('p-1! [&>svg]:transition [&>svg]:duration-200', expanded && '[&>svg]:rotate-180')}
124
128
  label={expanded ? 'Close' : 'Open'}
125
129
  onClick={() => setExpanded((expanded) => !expanded)}
126
130
  />
@@ -148,8 +152,8 @@ const ChatDialogContent = ({ children, classNames }: ChatDialogContentProps) =>
148
152
  return (
149
153
  <div
150
154
  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',
155
+ 'border-t border-b border-subdued-separator',
156
+ 'transition ease-in-out duration-0 [&:not([data-dx-resizing="true"])]:duration-200',
153
157
  classNames,
154
158
  )}
155
159
  style={{
@@ -7,7 +7,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
7
7
  import { log } from '@dxos/log';
8
8
  import { withTheme } from '@dxos/react-ui/testing';
9
9
 
10
- import { translations } from '../../translations';
10
+ import { translations } from '#translations';
11
11
 
12
12
  import { ChatEditor } from './ChatEditor';
13
13
  import { type ReferenceData } from './references';
@@ -24,13 +24,14 @@ const meta = {
24
24
 
25
25
  export default meta;
26
26
 
27
- const classNames = 'is-[20rem] p-2 border border-separator';
27
+ const classNames = 'w-[20rem] p-2 border border-separator';
28
28
 
29
29
  type Story = StoryObj<typeof meta>;
30
30
 
31
31
  export const Default: Story = {
32
32
  args: {
33
33
  classNames,
34
+ placeholder: 'Ask a question...',
34
35
  },
35
36
  };
36
37
 
@@ -41,12 +42,12 @@ export const WithSuggestions: Story = {
41
42
  onSubmit: (text) => {
42
43
  log('onSubmit', { text });
43
44
  },
45
+
44
46
  // onSuggest: (text) => {
45
47
  // const trimmed = text.trim().toLowerCase();
46
48
  // if (trimmed.length < 2) {
47
49
  // return [];
48
50
  // }
49
-
50
51
  // const suggestions = [
51
52
  // 'Create a kanban board',
52
53
  // 'Create a new project',
@@ -54,7 +55,6 @@ export const WithSuggestions: Story = {
54
55
  // "Let's play chess",
55
56
  // 'Show me Paris on a map',
56
57
  // ];
57
-
58
58
  // return suggestions.filter((s) => s.toLowerCase().startsWith(text));
59
59
  // },
60
60
  },
@@ -13,6 +13,7 @@ import {
13
13
  createBasicExtensions,
14
14
  createThemeExtensions,
15
15
  submit,
16
+ xmlFormatting,
16
17
  } from '@dxos/ui-editor';
17
18
  import { isTruthy } from '@dxos/util';
18
19
 
@@ -36,6 +37,7 @@ export const useChatExtensions = ({ extensions, lineWrapping = false, placeholde
36
37
  [
37
38
  createThemeExtensions({ themeMode }),
38
39
  createBasicExtensions({ bracketMatching: false, lineWrapping, placeholder }),
40
+ xmlFormatting(),
39
41
  submit({ onSubmit }),
40
42
  extensions,
41
43
  ].filter(isTruthy),
@@ -50,7 +52,7 @@ export const ChatEditor = forwardRef<ChatEditorController, ChatEditorProps>(
50
52
  // TODO(burdon): Popover.
51
53
  return (
52
54
  <Editor.Root ref={forwardRef}>
53
- <Editor.Content classNames={classNames} autoFocus={autoFocus} extensions={extensions} />
55
+ <Editor.View classNames={classNames} autoFocus={autoFocus} extensions={extensions} />
54
56
  </Editor.Root>
55
57
  );
56
58
  },
@@ -130,7 +130,7 @@ export const references = ({ provider, triggerCharacter = '@', debug = false }:
130
130
  }),
131
131
 
132
132
  autocompletion({
133
- tooltipClass: () => 'shadow rounded',
133
+ tooltipClass: () => 'shadow rounded-sm',
134
134
  activateOnTyping: true,
135
135
  aboveCursor: true,
136
136
  closeOnBlur: !debug,
@@ -0,0 +1,130 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useRef } from 'react';
7
+
8
+ import { Button, Toolbar } from '@dxos/react-ui';
9
+ import { Matrix } from '@dxos/react-ui-sfx';
10
+ import { withTheme, withLayout } from '@dxos/react-ui/testing';
11
+
12
+ import { ChatStatus, type ChatStatusController, useChatStatusContext } from './ChatStatus';
13
+
14
+ const meta = {
15
+ title: 'ui/react-ui-chat/ChatStatus',
16
+ component: ChatStatus.Root,
17
+ decorators: [
18
+ withTheme(),
19
+ withLayout({ layout: 'centered', classNames: 'py-1 px-2 border border-separator rounded-sm' }),
20
+ ],
21
+ parameters: {
22
+ layout: 'centered',
23
+ },
24
+ } satisfies Meta<typeof ChatStatus.Root>;
25
+
26
+ export default meta;
27
+
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ export const Default: Story = {
31
+ render: () => (
32
+ <ChatStatus.Root>
33
+ <ChatStatus.Icon />
34
+ <ChatStatus.Stopwatch />
35
+ </ChatStatus.Root>
36
+ ),
37
+ };
38
+
39
+ export const WithMeta: Story = {
40
+ render: () => (
41
+ <ChatStatus.Root>
42
+ <ChatStatus.Icon />
43
+ <ChatStatus.Stopwatch />
44
+ <ChatStatus.Separator />
45
+ <ChatStatus.Text>↑ 234</ChatStatus.Text>
46
+ <ChatStatus.Separator />
47
+ <ChatStatus.Text>↓ 1.2k</ChatStatus.Text>
48
+ </ChatStatus.Root>
49
+ ),
50
+ };
51
+
52
+ /**
53
+ * Reads the running flag from Status context and forwards it to the Matrix as
54
+ * the `active` prop, so the Matrix runs its own internal interval loop only
55
+ * while the Status tick is running. Pausing the Status freezes the dots.
56
+ */
57
+ const MatrixIcon = () => {
58
+ const { running } = useChatStatusContext('MatrixIcon');
59
+ return (
60
+ <Matrix
61
+ classNames='mr-2'
62
+ dim={4}
63
+ dotSize={3}
64
+ // TODO(burdon): Change with activity?
65
+ count={10}
66
+ interval={500}
67
+ active={running}
68
+ />
69
+ );
70
+ };
71
+
72
+ export const WithCustomIcon: Story = {
73
+ render: () => (
74
+ <ChatStatus.Root classNames='text-sm'>
75
+ <ChatStatus.Icon>
76
+ <MatrixIcon />
77
+ </ChatStatus.Icon>
78
+ <ChatStatus.Stopwatch />
79
+ <ChatStatus.Separator />
80
+ <ChatStatus.Text>↑ 234</ChatStatus.Text>
81
+ <ChatStatus.Separator />
82
+ <ChatStatus.Text>↓ 1.2k</ChatStatus.Text>
83
+ </ChatStatus.Root>
84
+ ),
85
+ };
86
+
87
+ export const LongRunning: Story = {
88
+ render: () => (
89
+ <ChatStatus.Root>
90
+ <ChatStatus.Icon />
91
+ <ChatStatus.Stopwatch offset={65 * 60} />
92
+ <ChatStatus.Separator />
93
+ <ChatStatus.Text>↑ 4.5k</ChatStatus.Text>
94
+ <ChatStatus.Separator />
95
+ <ChatStatus.Text>↓ 12.3k</ChatStatus.Text>
96
+ </ChatStatus.Root>
97
+ ),
98
+ };
99
+
100
+ export const TextOnly: Story = {
101
+ render: () => (
102
+ <ChatStatus.Root>
103
+ <ChatStatus.Icon />
104
+ <ChatStatus.Text>Connecting…</ChatStatus.Text>
105
+ </ChatStatus.Root>
106
+ ),
107
+ };
108
+
109
+ /**
110
+ * Demonstrates the imperative controller — start/stop the 1Hz tick from the outside.
111
+ */
112
+ export const Controller: Story = {
113
+ render: () => {
114
+ const ref = useRef<ChatStatusController>(null);
115
+ return (
116
+ <div className='flex flex-col gap-4'>
117
+ <Toolbar.Root>
118
+ <Button onClick={() => ref.current?.start()}>Start</Button>
119
+ <Button onClick={() => ref.current?.stop()}>Stop</Button>
120
+ </Toolbar.Root>
121
+ <ChatStatus.Root ref={ref}>
122
+ <ChatStatus.Icon>
123
+ <MatrixIcon />
124
+ </ChatStatus.Icon>
125
+ <ChatStatus.Stopwatch />
126
+ </ChatStatus.Root>
127
+ </div>
128
+ );
129
+ },
130
+ };
@@ -0,0 +1,183 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+ import React, { type PropsWithChildren, type Ref, forwardRef, useEffect, useImperativeHandle, useState } from 'react';
7
+
8
+ import { type ThemedClassName } from '@dxos/react-ui';
9
+ import { mx } from '@dxos/ui-theme';
10
+
11
+ import { formatElapsed } from './format';
12
+
13
+ const TICK_MS = 1_000;
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
+ //
31
+ // Controller
32
+ //
33
+
34
+ export type ChatStatusController = {
35
+ /** Resume the tick. No-op if already running. */
36
+ start: () => void;
37
+ /** Pause the tick. The reported `elapsed` freezes at its last value. */
38
+ stop: () => void;
39
+ };
40
+
41
+ //
42
+ // Root
43
+ //
44
+
45
+ export type RootProps = ThemedClassName<
46
+ PropsWithChildren<{
47
+ /** Whether the tick starts running on mount. Defaults to `true`. */
48
+ defaultRunning?: boolean;
49
+ }>
50
+ >;
51
+
52
+ const Root = forwardRef<ChatStatusController, RootProps>(
53
+ ({ classNames, children, defaultRunning = true }: RootProps, forwardedRef: Ref<ChatStatusController>) => {
54
+ const [elapsed, setElapsed] = useState(0);
55
+ const [running, setRunning] = useState(defaultRunning);
56
+
57
+ useEffect(() => {
58
+ if (!running) {
59
+ return;
60
+ }
61
+
62
+ // Anchor to a wall-clock timestamp so `elapsed` self-corrects after
63
+ // tab-throttling: browsers clamp setTimeout to >=1s in inactive tabs
64
+ // and >=1min when the page is hidden, so a count-the-ticks approach
65
+ // would under-report after a background period. Computing elapsed from
66
+ // (Date.now() - startedAt) restores the true wall-clock value on the
67
+ // next fired tick. The initial elapsed (preserved across stop/start)
68
+ // is folded into the anchor so paused intervals are excluded.
69
+ const startedAt = Date.now() - elapsed * TICK_MS;
70
+ let timeout: ReturnType<typeof setTimeout>;
71
+ const tick = () => {
72
+ setElapsed(Math.floor((Date.now() - startedAt) / TICK_MS));
73
+ timeout = setTimeout(tick, TICK_MS - (Date.now() % TICK_MS));
74
+ };
75
+ timeout = setTimeout(tick, TICK_MS - (Date.now() % TICK_MS));
76
+ return () => clearTimeout(timeout);
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `elapsed` is
78
+ // captured once at resume; depending on it would restart the timer on
79
+ // every tick.
80
+ }, [running]);
81
+
82
+ useImperativeHandle(
83
+ forwardedRef,
84
+ () => ({
85
+ start: () => setRunning(true),
86
+ stop: () => setRunning(false),
87
+ }),
88
+ [],
89
+ );
90
+
91
+ return (
92
+ <ChatStatusProvider elapsed={elapsed} running={running}>
93
+ <span className={mx('inline-flex items-center gap-2 text-description font-mono tabular-nums', classNames)}>
94
+ {children}
95
+ </span>
96
+ </ChatStatusProvider>
97
+ );
98
+ },
99
+ );
100
+
101
+ Root.displayName = 'ChatChatStatus.Root';
102
+
103
+ //
104
+ // Icon
105
+ //
106
+
107
+ export type IconProps = ThemedClassName<PropsWithChildren>;
108
+
109
+ /**
110
+ * Animated leading indicator. Defaults to a halo-pulse dot. Pass children to override.
111
+ */
112
+ const Icon = ({ classNames, children }: IconProps) => {
113
+ if (children !== undefined) {
114
+ return <>{children}</>;
115
+ }
116
+ return (
117
+ <span
118
+ aria-hidden='true'
119
+ className={mx('inline-block size-2 rounded-full bg-current animate-halo-pulse', classNames)}
120
+ />
121
+ );
122
+ };
123
+
124
+ //
125
+ // Stopwatch
126
+ //
127
+
128
+ export type StopwatchProps = ThemedClassName<{
129
+ /** Seconds to add to the context elapsed value before formatting. Defaults to 0. */
130
+ offset?: number;
131
+ }>;
132
+
133
+ /**
134
+ * Elapsed-time display, driven by the ChatStatus.Root context tick.
135
+ * Use `offset` to shift the displayed value; remount `ChatStatus.Root` to reset the underlying counter.
136
+ * Marked `aria-live='off'` so screen readers don't announce every tick.
137
+ */
138
+ const Stopwatch = ({ classNames, offset = 0 }: StopwatchProps) => {
139
+ const { elapsed } = useChatStatusContext('ChatChatStatus.Stopwatch');
140
+ return (
141
+ <span aria-live='off' className={mx(classNames)}>
142
+ {formatElapsed((elapsed + offset) * 1_000)}
143
+ </span>
144
+ );
145
+ };
146
+
147
+ //
148
+ // Separator
149
+ //
150
+
151
+ export type SeparatorProps = ThemedClassName<unknown>;
152
+
153
+ /**
154
+ * Middle-dot separator. Decorative — `aria-hidden`.
155
+ */
156
+ const Separator = ({ classNames }: SeparatorProps) => (
157
+ <span aria-hidden='true' className={mx('opacity-50', classNames)}>
158
+ ·
159
+ </span>
160
+ );
161
+
162
+ //
163
+ // Text
164
+ //
165
+
166
+ export type TextProps = ThemedClassName<PropsWithChildren>;
167
+
168
+ /**
169
+ * Generic text node — useful for token counts and other inline metadata.
170
+ */
171
+ const Text = ({ classNames, children }: TextProps) => <span className={mx(classNames)}>{children}</span>;
172
+
173
+ //
174
+ // ChatStatus
175
+ //
176
+
177
+ export const ChatStatus = {
178
+ Root,
179
+ Icon,
180
+ Stopwatch,
181
+ Separator,
182
+ Text,
183
+ };
@@ -0,0 +1,48 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { formatElapsed } from './format';
8
+
9
+ describe('formatElapsed', () => {
10
+ test('zero returns 0s', ({ expect }) => {
11
+ expect(formatElapsed(0)).toBe('0s');
12
+ });
13
+
14
+ test('negative inputs clamp to 0s', ({ expect }) => {
15
+ expect(formatElapsed(-1_000)).toBe('0s');
16
+ expect(formatElapsed(-Infinity)).toBe('0s');
17
+ });
18
+
19
+ test('seconds tier under one minute', ({ expect }) => {
20
+ expect(formatElapsed(999)).toBe('0s');
21
+ expect(formatElapsed(1_000)).toBe('1s');
22
+ expect(formatElapsed(12_400)).toBe('12s');
23
+ expect(formatElapsed(59_999)).toBe('59s');
24
+ });
25
+
26
+ test('boundary at one minute', ({ expect }) => {
27
+ expect(formatElapsed(60_000)).toBe('1m 0s');
28
+ expect(formatElapsed(60_999)).toBe('1m 0s');
29
+ expect(formatElapsed(61_000)).toBe('1m 1s');
30
+ });
31
+
32
+ test('minutes tier under one hour', ({ expect }) => {
33
+ expect(formatElapsed(92_000)).toBe('1m 32s');
34
+ expect(formatElapsed(3_540_000)).toBe('59m 0s');
35
+ expect(formatElapsed(3_599_999)).toBe('59m 59s');
36
+ });
37
+
38
+ test('boundary at one hour', ({ expect }) => {
39
+ expect(formatElapsed(3_600_000)).toBe('1h 0m');
40
+ expect(formatElapsed(3_660_000)).toBe('1h 1m');
41
+ });
42
+
43
+ test('hours tier drops smaller units', ({ expect }) => {
44
+ expect(formatElapsed(3_720_000)).toBe('1h 2m');
45
+ expect(formatElapsed(7_323_000)).toBe('2h 2m');
46
+ expect(formatElapsed(36_000_000)).toBe('10h 0m');
47
+ });
48
+ });
@@ -0,0 +1,27 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ const SECOND = 1_000;
6
+ const MINUTE = 60 * SECOND;
7
+ const HOUR = 60 * MINUTE;
8
+
9
+ /**
10
+ * Formats elapsed milliseconds as a human-readable duration.
11
+ * Tiers: `Ns` (< 60s), `Nm Xs` (< 60m), `Nh Xm` (≥ 60m).
12
+ * Negative inputs clamp to `0s`.
13
+ */
14
+ export const formatElapsed = (ms: number): string => {
15
+ const safe = Math.max(0, ms);
16
+ if (safe < MINUTE) {
17
+ return `${Math.floor(safe / SECOND)}s`;
18
+ }
19
+ if (safe < HOUR) {
20
+ const m = Math.floor(safe / MINUTE);
21
+ const s = Math.floor((safe % MINUTE) / SECOND);
22
+ return `${m}m ${s}s`;
23
+ }
24
+ const h = Math.floor(safe / HOUR);
25
+ const m = Math.floor((safe % HOUR) / MINUTE);
26
+ return `${h}h ${m}m`;
27
+ };
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ChatStatus';
6
+ export * from './format';
@@ -4,3 +4,4 @@
4
4
 
5
5
  export * from './ChatDialog';
6
6
  export * from './ChatEditor';
7
+ export * from './ChatStatus';