@dxos/react-ui-assistant 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 (91) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/index.mjs +839 -0
  3. package/dist/lib/index.mjs.map +1 -0
  4. package/dist/lib/testing.mjs +214 -0
  5. package/dist/lib/testing.mjs.map +1 -0
  6. package/dist/types/src/components/ChatThread/ChatThread.d.ts +52 -0
  7. package/dist/types/src/components/ChatThread/ChatThread.d.ts.map +1 -0
  8. package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts +38 -0
  9. package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts.map +1 -0
  10. package/dist/types/src/components/ChatThread/index.d.ts +2 -0
  11. package/dist/types/src/components/ChatThread/index.d.ts.map +1 -0
  12. package/dist/types/src/components/MessageChrome/MessageChrome.d.ts +28 -0
  13. package/dist/types/src/components/MessageChrome/MessageChrome.d.ts.map +1 -0
  14. package/dist/types/src/components/MessageChrome/index.d.ts +2 -0
  15. package/dist/types/src/components/MessageChrome/index.d.ts.map +1 -0
  16. package/dist/types/src/components/index.d.ts +4 -0
  17. package/dist/types/src/components/index.d.ts.map +1 -0
  18. package/dist/types/src/components/widgets/FallbackWidget.d.ts +7 -0
  19. package/dist/types/src/components/widgets/FallbackWidget.d.ts.map +1 -0
  20. package/dist/types/src/components/widgets/ReasoningWidget.d.ts +25 -0
  21. package/dist/types/src/components/widgets/ReasoningWidget.d.ts.map +1 -0
  22. package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts +18 -0
  23. package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts.map +1 -0
  24. package/dist/types/src/components/widgets/ReferenceWidget.d.ts +9 -0
  25. package/dist/types/src/components/widgets/ReferenceWidget.d.ts.map +1 -0
  26. package/dist/types/src/components/widgets/SelectWidget.d.ts +14 -0
  27. package/dist/types/src/components/widgets/SelectWidget.d.ts.map +1 -0
  28. package/dist/types/src/components/widgets/StatsWidget.d.ts +12 -0
  29. package/dist/types/src/components/widgets/StatsWidget.d.ts.map +1 -0
  30. package/dist/types/src/components/widgets/StatusWidget.d.ts +12 -0
  31. package/dist/types/src/components/widgets/StatusWidget.d.ts.map +1 -0
  32. package/dist/types/src/components/widgets/SuggestionWidget.d.ts +11 -0
  33. package/dist/types/src/components/widgets/SuggestionWidget.d.ts.map +1 -0
  34. package/dist/types/src/components/widgets/SummaryWidget.d.ts +4 -0
  35. package/dist/types/src/components/widgets/SummaryWidget.d.ts.map +1 -0
  36. package/dist/types/src/components/widgets/ToolWidget.d.ts +10 -0
  37. package/dist/types/src/components/widgets/ToolWidget.d.ts.map +1 -0
  38. package/dist/types/src/components/widgets/ToolWidget.stories.d.ts +31 -0
  39. package/dist/types/src/components/widgets/ToolWidget.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/widgets/defaults.d.ts +11 -0
  41. package/dist/types/src/components/widgets/defaults.d.ts.map +1 -0
  42. package/dist/types/src/components/widgets/format-time.d.ts +2 -0
  43. package/dist/types/src/components/widgets/format-time.d.ts.map +1 -0
  44. package/dist/types/src/components/widgets/index.d.ts +11 -0
  45. package/dist/types/src/components/widgets/index.d.ts.map +1 -0
  46. package/dist/types/src/index.d.ts +6 -0
  47. package/dist/types/src/index.d.ts.map +1 -0
  48. package/dist/types/src/registry.d.ts +11 -0
  49. package/dist/types/src/registry.d.ts.map +1 -0
  50. package/dist/types/src/renderer.d.ts +25 -0
  51. package/dist/types/src/renderer.d.ts.map +1 -0
  52. package/dist/types/src/testing/index.d.ts +2 -0
  53. package/dist/types/src/testing/index.d.ts.map +1 -0
  54. package/dist/types/src/testing/test-generator.d.ts +7 -0
  55. package/dist/types/src/testing/test-generator.d.ts.map +1 -0
  56. package/dist/types/src/testing/test-generator.test.d.ts +2 -0
  57. package/dist/types/src/testing/test-generator.test.d.ts.map +1 -0
  58. package/dist/types/src/translations.d.ts +19 -0
  59. package/dist/types/src/translations.d.ts.map +1 -0
  60. package/dist/types/src/types.d.ts +28 -0
  61. package/dist/types/src/types.d.ts.map +1 -0
  62. package/dist/types/tsconfig.tsbuildinfo +1 -0
  63. package/package.json +67 -0
  64. package/src/components/ChatThread/ChatThread.stories.tsx +419 -0
  65. package/src/components/ChatThread/ChatThread.tsx +188 -0
  66. package/src/components/ChatThread/index.ts +5 -0
  67. package/src/components/MessageChrome/MessageChrome.tsx +147 -0
  68. package/src/components/MessageChrome/index.ts +5 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/widgets/FallbackWidget.tsx +26 -0
  71. package/src/components/widgets/ReasoningWidget.stories.tsx +68 -0
  72. package/src/components/widgets/ReasoningWidget.ts +139 -0
  73. package/src/components/widgets/ReferenceWidget.ts +28 -0
  74. package/src/components/widgets/SelectWidget.ts +39 -0
  75. package/src/components/widgets/StatsWidget.ts +31 -0
  76. package/src/components/widgets/StatusWidget.ts +53 -0
  77. package/src/components/widgets/SuggestionWidget.ts +50 -0
  78. package/src/components/widgets/SummaryWidget.tsx +27 -0
  79. package/src/components/widgets/ToolWidget.stories.tsx +55 -0
  80. package/src/components/widgets/ToolWidget.tsx +185 -0
  81. package/src/components/widgets/defaults.ts +14 -0
  82. package/src/components/widgets/format-time.ts +45 -0
  83. package/src/components/widgets/index.ts +14 -0
  84. package/src/index.ts +9 -0
  85. package/src/registry.tsx +144 -0
  86. package/src/renderer.ts +216 -0
  87. package/src/testing/index.ts +5 -0
  88. package/src/testing/test-generator.test.ts +75 -0
  89. package/src/testing/test-generator.ts +280 -0
  90. package/src/translations.ts +27 -0
  91. package/src/types.ts +32 -0
@@ -0,0 +1,147 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+ import React, { type PropsWithChildren } from 'react';
7
+
8
+ import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
9
+ import { type MessageChromeProps } from '@dxos/react-ui-feed';
10
+ import { Message } from '@dxos/types';
11
+ import { mx } from '@dxos/ui-theme';
12
+
13
+ import { translationKey } from '../../translations';
14
+ import { formatTime } from '../widgets';
15
+
16
+ //
17
+ // Context
18
+ //
19
+
20
+ const MESSAGE_CHROME_NAME = 'MessageChrome';
21
+
22
+ type MessageChromeContextValue = {
23
+ /** Soft-fork the thread from the given prompt; the rewind button renders only when present. */
24
+ onRewind?: (id: string) => void;
25
+ };
26
+
27
+ const [MessageChromeProvider, useMessageChromeContext] = createContext<MessageChromeContextValue>(MESSAGE_CHROME_NAME);
28
+
29
+ export { MessageChromeProvider };
30
+
31
+ //
32
+ // Toolbars
33
+ //
34
+
35
+ /** Copies the message's extracted text — the model's truth, not the DOM's partial render. */
36
+ const CopyButton = ({ message }: { message: Message.Message }) => {
37
+ const { t } = useTranslation(translationKey);
38
+ return (
39
+ <IconButton
40
+ icon='ph--copy--regular'
41
+ iconOnly
42
+ label={t('copy.label')}
43
+ variant='ghost'
44
+ density='sm'
45
+ onClick={() => void navigator.clipboard?.writeText(Message.extractText(message))}
46
+ />
47
+ );
48
+ };
49
+
50
+ const timeOf = (message: Message.Message) => (
51
+ <time dateTime={message.created} title={new Date(message.created).toLocaleString()}>
52
+ {formatTime(message.created)}
53
+ </time>
54
+ );
55
+
56
+ export type MessageToolbarProps = ThemedClassName<{
57
+ message: Message.Message;
58
+ }>;
59
+
60
+ /**
61
+ * The controls under the reader's own prompt: copy, rewind (edit-and-resend), and when it was
62
+ * sent. Revealed on hover by the chrome, but never removed from flow — chrome that appears and
63
+ * disappears changes the row's height, and a pointer travelling down a scrolling list would then
64
+ * move every row below it.
65
+ */
66
+ export const PromptToolbar = ({ classNames, message }: MessageToolbarProps) => {
67
+ const { t } = useTranslation(translationKey);
68
+ const { onRewind } = useMessageChromeContext('PromptToolbar');
69
+
70
+ return (
71
+ <div role='toolbar' className={mx('flex items-center gap-1 text-xs text-description', classNames)}>
72
+ <CopyButton message={message} />
73
+ {onRewind && (
74
+ <IconButton
75
+ icon='ph--clock-counter-clockwise--regular'
76
+ iconOnly
77
+ label={t('rewind.label')}
78
+ variant='ghost'
79
+ density='sm'
80
+ data-testid='chat.rewind'
81
+ onClick={() => onRewind(message.id)}
82
+ />
83
+ )}
84
+ {timeOf(message)}
85
+ </div>
86
+ );
87
+ };
88
+
89
+ PromptToolbar.displayName = 'PromptToolbar';
90
+
91
+ /** The controls under an answer: copy, and when the answer finished. */
92
+ export const AssistantToolbar = ({ classNames, message }: MessageToolbarProps) => {
93
+ return (
94
+ <div role='toolbar' className={mx('flex items-center gap-1 text-xs text-description', classNames)}>
95
+ <CopyButton message={message} />
96
+ {timeOf(message)}
97
+ </div>
98
+ );
99
+ };
100
+
101
+ AssistantToolbar.displayName = 'AssistantToolbar';
102
+
103
+ //
104
+ // Chrome
105
+ //
106
+
107
+ /** Shared hover reveal: present in flow at all times, visible when the row is under the pointer. */
108
+ const reveal = 'pt-1 opacity-0 transition-opacity group-hover:opacity-100';
109
+
110
+ const Row = ({ children, classNames }: PropsWithChildren<{ classNames?: string }>) => (
111
+ <div className={mx('group relative px-2 py-2', classNames)} data-testid='feed.message'>
112
+ {children}
113
+ </div>
114
+ );
115
+
116
+ /**
117
+ * The assistant feed's per-message frame: the reader's prompts and the model's answers are framed
118
+ * differently because they are different kinds of thing — a prompt is an instruction the thread
119
+ * can be rewound to, an answer is a result.
120
+ *
121
+ * A prompt sits apart from the model's prose: right-aligned, at most two thirds of the viewport —
122
+ * the chat convention that makes whose-turn legible at a glance — with its toolbar following the
123
+ * bubble's edge.
124
+ */
125
+ export const MessageChrome = ({ message, selected, children }: MessageChromeProps) => {
126
+ const prompt = message.sender.role === 'user';
127
+
128
+ return (
129
+ <Row classNames={mx(selected && 'bg-hover-surface')}>
130
+ {prompt ? (
131
+ <div className='min-w-0 flex flex-col items-end'>
132
+ <div className='max-w-[66%] min-w-0'>
133
+ <div className='px-4 py-3 border-s-2 border-accent-bg rounded-sm bg-input-surface'>{children}</div>
134
+ <PromptToolbar classNames={mx('justify-end', reveal)} message={message} />
135
+ </div>
136
+ </div>
137
+ ) : (
138
+ <div className='min-w-0'>
139
+ {children}
140
+ <AssistantToolbar classNames={reveal} message={message} />
141
+ </div>
142
+ )}
143
+ </Row>
144
+ );
145
+ };
146
+
147
+ MessageChrome.displayName = MESSAGE_CHROME_NAME;
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './MessageChrome';
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './ChatThread';
6
+ export * from './MessageChrome';
7
+ export * from './widgets';
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { TogglePanel } from '@dxos/react-ui-components';
8
+ import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
9
+ import { type XmlWidgetProps } from '@dxos/ui-editor';
10
+
11
+ export const FallbackWidget = ({ _tag, ...props }: XmlWidgetProps) => {
12
+ return (
13
+ <TogglePanel.Root>
14
+ <TogglePanel.Content>
15
+ <TogglePanel.Header classNames='dx-group-surface'>{_tag}</TogglePanel.Header>
16
+ <TogglePanel.Body classNames='dx-modal-surface'>
17
+ <TogglePanel.Viewport>
18
+ <JsonHighlighter classNames='p-2! text-sm' data={props} />
19
+ </TogglePanel.Viewport>
20
+ </TogglePanel.Body>
21
+ </TogglePanel.Content>
22
+ </TogglePanel.Root>
23
+ );
24
+ };
25
+
26
+ FallbackWidget.displayName = 'Fallback';
@@ -0,0 +1,68 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useEffect, useRef } from 'react';
7
+
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
+
10
+ import { ReasoningWidget } from './ReasoningWidget';
11
+
12
+ export type StoryArgs = {
13
+ text: string;
14
+ };
15
+
16
+ // TODO(burdon): Generalize factory for DOM widgets.
17
+ const DefaultStory = ({ text }: StoryArgs) => {
18
+ const hostRef = useRef<HTMLDivElement>(null);
19
+ useEffect(() => {
20
+ const host = hostRef.current;
21
+ if (!host) {
22
+ return;
23
+ }
24
+
25
+ host.replaceChildren();
26
+ const widget = new ReasoningWidget(text, 0);
27
+ const dom = widget.toDOM();
28
+ host.appendChild(dom);
29
+ widget.updateDOM(dom);
30
+ return () => {
31
+ widget.destroy(dom);
32
+ host.replaceChildren();
33
+ };
34
+ }, [text]);
35
+
36
+ return <div ref={hostRef} />;
37
+ };
38
+
39
+ const meta = {
40
+ title: 'ui/react-ui-assistant/widgets/ReasoningWidget',
41
+ component: DefaultStory,
42
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
43
+ parameters: {
44
+ layout: 'fullscreen',
45
+ },
46
+ } satisfies Meta<typeof DefaultStory>;
47
+
48
+ export default meta;
49
+
50
+ type Story = StoryObj<typeof meta>;
51
+
52
+ export const Default: Story = {
53
+ args: {
54
+ text: 'The user is asking about nested flex layouts and overflow. I should mention min-height 0 and grid track sizing.',
55
+ },
56
+ };
57
+
58
+ export const LongText: Story = {
59
+ args: {
60
+ text: [
61
+ 'Step 1: Identify the scroll container and ensure it participates in a definite height chain.',
62
+ 'Step 2: Apply min-h-0 to flex children that should shrink and scroll.',
63
+ 'Step 3: Prefer overflow-y-auto on the leaf viewport, not on every ancestor.',
64
+ 'Step 4: Use min-h-0 on the scroll container to ensure it participates in a definite height chain.',
65
+ 'Step 5: Use min-w-0 on the scroll container to ensure it participates in a definite width chain.',
66
+ ].join('\n\n'),
67
+ },
68
+ };
@@ -0,0 +1,139 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WidgetType } from '@codemirror/view';
6
+
7
+ import { Domino } from '@dxos/ui';
8
+
9
+ import { styles } from './defaults';
10
+
11
+ /**
12
+ * Props used to derive a stable key for a reasoning block across CodeMirror widget rebuilds.
13
+ * CodeMirror does not assign persistent IDs to widgets; streaming re-runs the factory each tick.
14
+ */
15
+ export type ReasoningWidgetBlockIdProps = {
16
+ id?: string;
17
+ range?: { from: number; to: number };
18
+ };
19
+
20
+ /**
21
+ * AI reasoning widget.
22
+ */
23
+ export class ReasoningWidget extends WidgetType {
24
+ readonly #pos: string;
25
+ /** The timer id last registered for this instance (may differ from the map if superseded). */
26
+ #ownedTimerId: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ constructor(
29
+ private readonly text: string,
30
+ pos: string | number | undefined,
31
+ ) {
32
+ super();
33
+ this.#pos = pos === undefined || pos === '' ? 'reasoning' : String(pos);
34
+ }
35
+
36
+ override eq(other: this) {
37
+ return this.text === other.text && this.#pos === other.#pos;
38
+ }
39
+
40
+ override toDOM() {
41
+ return Domino.of('div')
42
+ .classNames(styles.padding)
43
+ .append(
44
+ Domino.of('div')
45
+ .classNames('relative overflow-hidden p-px', styles.border)
46
+ .attributes({ 'data-trail-container': '' })
47
+ .append(
48
+ Domino.of('div')
49
+ .classNames(
50
+ 'relative z-10 dx-base-surface rounded-sm text-sm text-subdued py-1',
51
+ 'grid grid-cols-[24px_1fr] gap-x-0.5 items-start',
52
+ )
53
+ .append(
54
+ Domino.of('div')
55
+ .classNames('flex h-5 w-full shrink-0 items-center justify-center self-start')
56
+ .append(Domino.svg('ph--brain--regular').classNames('shrink-0 size-4 opacity-70')),
57
+ Domino.of('div')
58
+ .classNames('px-2 max-h-[5lh] overflow-y-auto dx-scrollbar-thin min-w-0')
59
+ .text(this.text)
60
+ .attributes({ 'data-reasoning-text': '' }),
61
+ ),
62
+ Domino.of('div').attributes({ 'data-id': this.#pos }),
63
+ ),
64
+ ).root;
65
+ }
66
+
67
+ override updateDOM(dom: HTMLElement) {
68
+ // Update only the text leaf; `dom` may be wrapped in an outer shell, and the trail host is a
69
+ // sibling of the text node inside the bordered container — setting `textContent` on the wrong
70
+ // ancestor would remove `[data-id]`.
71
+ dom.querySelector<HTMLElement>('[data-reasoning-text]')?.replaceChildren(this.text);
72
+
73
+ // Recreate trail host if it was removed by a prior timeout (streaming paused > TRAIL_REMOVAL_DELAY_MS).
74
+ const container = dom.querySelector<HTMLElement>('[data-trail-container]');
75
+ let trailHost = container?.querySelector<HTMLElement>('[data-id]');
76
+ if (container && !trailHost) {
77
+ trailHost = Domino.of('div').attributes({ 'data-id': this.#pos }).root;
78
+ container.append(trailHost);
79
+ }
80
+ if (trailHost?.childElementCount === 0) {
81
+ trailHost.append(...createTrailLayers());
82
+ }
83
+
84
+ this.#scheduleTrailRemoval(dom);
85
+ return true;
86
+ }
87
+
88
+ override destroy(_dom: HTMLElement) {
89
+ this.#clearOwnedTrailTimer();
90
+ }
91
+
92
+ #scheduleTrailRemoval(dom: HTMLElement) {
93
+ const previous = trailRemovalTimers.get(this.#pos);
94
+ if (previous !== undefined) {
95
+ clearTimeout(previous);
96
+ }
97
+
98
+ const timerId = setTimeout(() => {
99
+ if (trailRemovalTimers.get(this.#pos) !== timerId) {
100
+ return;
101
+ }
102
+ trailRemovalTimers.delete(this.#pos);
103
+ this.#ownedTimerId = null;
104
+ dom.querySelector<HTMLElement>('[data-id]')?.remove();
105
+ }, TRAIL_REMOVAL_DELAY_MS);
106
+
107
+ trailRemovalTimers.set(this.#pos, timerId);
108
+ this.#ownedTimerId = timerId;
109
+ }
110
+
111
+ #clearOwnedTrailTimer() {
112
+ const active = trailRemovalTimers.get(this.#pos);
113
+ if (active !== undefined && active === this.#ownedTimerId) {
114
+ clearTimeout(active);
115
+ trailRemovalTimers.delete(this.#pos);
116
+ }
117
+ this.#ownedTimerId = null;
118
+ }
119
+ }
120
+
121
+ const trail = [
122
+ 'absolute z-0 aspect-[2/1] w-16',
123
+ 'bg-[radial-gradient(at_100%_50%,_theme(colors.green.700),_transparent_80%)]',
124
+ '[offset-anchor:100%_50%] [offset-path:border-box]',
125
+ ];
126
+
127
+ /** Delay after the last content update before hiding the trail. */
128
+ const TRAIL_REMOVAL_DELAY_MS = 1_000;
129
+
130
+ /**
131
+ * One pending removal timer per logical block. Streaming creates a new widget instance each tick;
132
+ * `destroy` may run after the next instance has already scheduled, so we must not clear a newer timer.
133
+ */
134
+ const trailRemovalTimers = new Map<string, ReturnType<typeof setTimeout>>();
135
+
136
+ const createTrailLayers = (): [HTMLElement, HTMLElement] => [
137
+ Domino.of('div').classNames(...trail, 'animate-trail').root,
138
+ Domino.of('div').classNames(...trail, 'animate-trail-offset').root,
139
+ ];
@@ -0,0 +1,28 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WidgetType } from '@codemirror/view';
6
+
7
+ import { Domino } from '@dxos/ui';
8
+
9
+ import { styles } from './defaults';
10
+
11
+ export class ReferenceWidget extends WidgetType {
12
+ constructor(
13
+ private text: string,
14
+ private dxn: string,
15
+ ) {
16
+ super();
17
+ }
18
+
19
+ override eq(other: this) {
20
+ return this.dxn === other.dxn;
21
+ }
22
+
23
+ override toDOM() {
24
+ return Domino.of('div')
25
+ .classNames(styles.padding)
26
+ .append(Domino.of('dx-anchor').classNames('dx-tag--anchor').attributes({ dxn: this.dxn }).text(this.text)).root;
27
+ }
28
+ }
@@ -0,0 +1,39 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WidgetType } from '@codemirror/view';
6
+
7
+ import { Domino } from '@dxos/ui';
8
+
9
+ import { styles } from './defaults';
10
+
11
+ /**
12
+ * Simple prompt widget.
13
+ */
14
+ export class SelectWidget extends WidgetType {
15
+ constructor(private options: string[]) {
16
+ super();
17
+ }
18
+
19
+ override eq(other: this) {
20
+ return JSON.stringify(this.options) === JSON.stringify(other.options);
21
+ }
22
+
23
+ /**
24
+ * NOTE: Container must set var based on user's identity.
25
+ */
26
+ override toDOM() {
27
+ return Domino.of('div')
28
+ .attributes({ role: 'group' })
29
+ .classNames(styles.padding, 'flex flex-wrap gap-1')
30
+ .append(
31
+ ...this.options.map((option) =>
32
+ Domino.of('button')
33
+ .classNames('dx-button inline-block max-w-[100cqi]')
34
+ .attributes({ 'data-action': 'submit', 'data-value': option, 'data-density': 'md' })
35
+ .text(option),
36
+ ),
37
+ ).root;
38
+ }
39
+ }
@@ -0,0 +1,31 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WidgetType } from '@codemirror/view';
6
+
7
+ import { Domino } from '@dxos/ui';
8
+
9
+ import { styles } from './defaults';
10
+
11
+ /**
12
+ * Simple stats widget.
13
+ */
14
+ export class StatsWidget extends WidgetType {
15
+ constructor(private text: string) {
16
+ super();
17
+ }
18
+
19
+ override eq(other: this) {
20
+ return this.text === other.text;
21
+ }
22
+
23
+ override toDOM() {
24
+ return Domino.of('div').classNames(styles.padding, 'text-sm text-placeholder').text(this.text).root;
25
+ }
26
+
27
+ override updateDOM(dom: HTMLElement) {
28
+ dom.textContent = this.text;
29
+ return true;
30
+ }
31
+ }
@@ -0,0 +1,53 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WidgetType } from '@codemirror/view';
6
+
7
+ import { Domino } from '@dxos/ui';
8
+
9
+ import { styles } from './defaults';
10
+
11
+ /**
12
+ * AI status widget.
13
+ */
14
+ export class StatusWidget extends WidgetType {
15
+ constructor(private readonly text: string) {
16
+ super();
17
+ }
18
+
19
+ override eq(other: this) {
20
+ return this.text === other.text;
21
+ }
22
+
23
+ override toDOM() {
24
+ return Domino.of('div')
25
+ .classNames(styles.padding)
26
+ .append(
27
+ Domino.of('div')
28
+ .classNames('relative overflow-hidden rounded-sm')
29
+ .append(
30
+ Domino.of('div')
31
+ .classNames('grid grid-cols-[24px_1fr] gap-x-0.5 gap-y-0 items-start px-0.5 py-0.5 text-placeholder')
32
+ .append(
33
+ Domino.of('div')
34
+ .classNames('flex h-5 w-full shrink-0 items-center justify-center self-start')
35
+ .append(Domino.svg('ph--info--regular').classNames('shrink-0 size-4 opacity-70')),
36
+ Domino.of('div')
37
+ .classNames('relative min-w-0')
38
+ .append(
39
+ Domino.of('div')
40
+ .classNames('relative z-10 rounded-sm text-sm leading-5')
41
+ .attributes({ 'data-status-text': '' })
42
+ .text(this.text),
43
+ ),
44
+ ),
45
+ ),
46
+ ).root;
47
+ }
48
+
49
+ override updateDOM(dom: HTMLElement) {
50
+ dom.querySelector<HTMLElement>('[data-status-text]')?.replaceChildren(this.text);
51
+ return true;
52
+ }
53
+ }
@@ -0,0 +1,50 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WidgetType } from '@codemirror/view';
6
+
7
+ import { Domino, mx } from '@dxos/ui';
8
+
9
+ /**
10
+ * Simple prompt widget.
11
+ */
12
+ export class SuggestionWidget extends WidgetType {
13
+ constructor(private text: string) {
14
+ super();
15
+ }
16
+
17
+ override eq(other: this) {
18
+ return this.text === other.text;
19
+ }
20
+
21
+ override toDOM() {
22
+ // NOTE: Container must have `dx-inline-size-container` (or `dx-size-container`) to support cqi.
23
+ //
24
+ // Inline-level root so consecutive suggestions flow onto one wrapped line rather than stacking —
25
+ // the tag is registered `block: false` and the renderer omits the usual block separator between
26
+ // them.
27
+ //
28
+ // Vertical space must be **padding, never margin**: CodeMirror measures a widget from its own box,
29
+ // and a margin sits outside what it can see, so the height model drifts from what is rendered, the
30
+ // error accumulates down the document, and the turn-fold gutter markers end up visibly misaligned.
31
+ // Adjacent chips are likewise spaced with trailing padding rather than a margin.
32
+ return Domino.of('span')
33
+ .classNames(mx('inline-flex max-w-[calc(100cqi-8px)] py-1 pe-1 overflow-hidden'))
34
+ .append(
35
+ Domino.of('button')
36
+ .attributes({
37
+ 'data-density': 'md',
38
+ 'data-action': 'submit',
39
+ 'data-value': this.text,
40
+ })
41
+ .classNames(mx('dx-button gap-2 w-full overflow-hidden'))
42
+ .append(
43
+ Domino.of('dx-icon').attributes({
44
+ icon: 'ph--lightning--regular',
45
+ }),
46
+ Domino.of('span').classNames('flex-1 truncate min-w-0').text(this.text),
47
+ ),
48
+ ).root;
49
+ }
50
+ }
@@ -0,0 +1,27 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { useTranslation } from '@dxos/react-ui';
8
+ import { TogglePanel } from '@dxos/react-ui-components';
9
+ import { type XmlWidgetProps, getXmlTextChild } from '@dxos/ui-editor';
10
+
11
+ import { translationKey } from '../../translations';
12
+ import { styles } from './defaults';
13
+
14
+ export const SummaryWidget = ({ children }: XmlWidgetProps) => {
15
+ const { t } = useTranslation(translationKey);
16
+
17
+ return (
18
+ <TogglePanel.Root>
19
+ <TogglePanel.Content classNames={styles.border}>
20
+ <TogglePanel.Header classNames='text-sm dx-group-surface'>{t('summary.label')}</TogglePanel.Header>
21
+ <TogglePanel.Body>
22
+ <div className='p-1 text-sm text-subdued'>{getXmlTextChild(children ?? [])}</div>
23
+ </TogglePanel.Body>
24
+ </TogglePanel.Content>
25
+ </TogglePanel.Root>
26
+ );
27
+ };
@@ -0,0 +1,55 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+
7
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
8
+ import { type ContentBlock } from '@dxos/types';
9
+
10
+ import { translations } from '../../translations';
11
+ import { ToolWidget, type ToolWidgetProps } from './ToolWidget';
12
+
13
+ const baseWidgetProps = {
14
+ _tag: 'toolkit',
15
+ range: { from: 0, to: 1 },
16
+ } satisfies Pick<ToolWidgetProps, '_tag' | 'range'>;
17
+
18
+ const toolCallAndResult: ContentBlock.Any[] = [
19
+ {
20
+ _tag: 'toolCall',
21
+ toolCallId: 'tc-story-1',
22
+ name: 'example_tool',
23
+ input: JSON.stringify({ query: 'status', limit: 10 }),
24
+ providerExecuted: false,
25
+ },
26
+ {
27
+ _tag: 'toolResult',
28
+ toolCallId: 'tc-story-1',
29
+ name: 'example_tool',
30
+ result: JSON.stringify({ ok: true, rows: [{ id: 1 }, { id: 2 }] }),
31
+ providerExecuted: false,
32
+ },
33
+ ];
34
+
35
+ const meta = {
36
+ title: 'ui/react-ui-assistant/widgets/ToolWidget',
37
+ component: ToolWidget,
38
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
39
+ parameters: {
40
+ layout: 'fullscreen',
41
+ translations,
42
+ },
43
+ } satisfies Meta<typeof ToolWidget>;
44
+
45
+ export default meta;
46
+
47
+ type Story = StoryObj<typeof meta>;
48
+
49
+ export const Default: Story = {
50
+ args: {
51
+ ...baseWidgetProps,
52
+ // The tag carries the run as JSON text; the widget parses it back out.
53
+ children: [JSON.stringify(toolCallAndResult)],
54
+ },
55
+ };