@dxos/react-ui-chat 0.8.4-main.dedc0f3 → 0.8.4-main.dfabb4ec29
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/dist/lib/browser/index.mjs +232 -224
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +14 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +232 -224
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +16 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/ChatDialog/ChatDialog.d.ts +17 -1
- package/dist/types/src/components/ChatDialog/ChatDialog.d.ts.map +1 -1
- package/dist/types/src/components/ChatDialog/ChatDialog.stories.d.ts +2 -3
- package/dist/types/src/components/ChatDialog/ChatDialog.stories.d.ts.map +1 -1
- package/dist/types/src/components/ChatEditor/ChatEditor.d.ts +7 -8
- package/dist/types/src/components/ChatEditor/ChatEditor.d.ts.map +1 -1
- package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts +3 -4
- package/dist/types/src/components/ChatEditor/ChatEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/ChatEditor/references.d.ts +4 -0
- package/dist/types/src/components/ChatEditor/references.d.ts.map +1 -1
- package/dist/types/src/components/ChatStatus/ChatStatus.d.ts +40 -0
- package/dist/types/src/components/ChatStatus/ChatStatus.d.ts.map +1 -0
- package/dist/types/src/components/ChatStatus/ChatStatus.stories.d.ts +27 -0
- package/dist/types/src/components/ChatStatus/ChatStatus.stories.d.ts.map +1 -0
- package/dist/types/src/components/ChatStatus/format.d.ts +7 -0
- package/dist/types/src/components/ChatStatus/format.d.ts.map +1 -0
- package/dist/types/src/components/ChatStatus/format.test.d.ts +2 -0
- package/dist/types/src/components/ChatStatus/format.test.d.ts.map +1 -0
- package/dist/types/src/components/ChatStatus/index.d.ts +3 -0
- package/dist/types/src/components/ChatStatus/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +2 -2
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +48 -39
- package/src/components/ChatDialog/ChatDialog.stories.tsx +4 -6
- package/src/components/ChatDialog/ChatDialog.tsx +28 -18
- package/src/components/ChatEditor/ChatEditor.stories.tsx +20 -20
- package/src/components/ChatEditor/ChatEditor.tsx +34 -64
- package/src/components/ChatEditor/references.ts +18 -12
- package/src/components/ChatStatus/ChatStatus.stories.tsx +130 -0
- package/src/components/ChatStatus/ChatStatus.tsx +183 -0
- package/src/components/ChatStatus/format.test.ts +48 -0
- package/src/components/ChatStatus/format.ts +27 -0
- package/src/components/ChatStatus/index.ts +6 -0
- package/src/components/index.ts +1 -0
- package/src/index.ts +0 -1
- package/src/translations.ts +1 -1
|
@@ -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
|
+
};
|
package/src/components/index.ts
CHANGED
package/src/index.ts
CHANGED