@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,185 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import type * as Tool from 'effect/unstable/ai/Tool';
6
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+
8
+ import { Icon, IconBlock, useTranslation } from '@dxos/react-ui';
9
+ import { NumericTabs, TextCrawl, TogglePanel, type TogglePanelRootProps } from '@dxos/react-ui-components';
10
+ import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
11
+ import { type ContentBlock } from '@dxos/types';
12
+ import { type XmlWidgetProps, getXmlTextChild } from '@dxos/ui-editor';
13
+ import { isNonNullable, safeParseJson } from '@dxos/util';
14
+
15
+ import { translationKey } from '../../translations';
16
+
17
+ export type ToolWidgetProps = XmlWidgetProps;
18
+
19
+ /**
20
+ * A run of tool blocks as one collapsible panel with a tab per call. The `<toolkit>` tag carries
21
+ * the run as JSON — the item is rebuilt from its message alone, so there is no widget-state side
22
+ * channel to accumulate blocks through.
23
+ */
24
+ export const ToolWidget = ({ view, children }: ToolWidgetProps) => {
25
+ const { t } = useTranslation(translationKey);
26
+ const blocks = useMemo<ContentBlock.Any[]>(() => {
27
+ const parsed = safeParseJson(getXmlTextChild(children ?? []) ?? '');
28
+ return Array.isArray(parsed) ? (parsed as ContentBlock.Any[]) : [];
29
+ }, [children]);
30
+
31
+ const items = useMemo<ToolPanelProps['items']>(() => {
32
+ let lastToolCall: { tool: Tool.Any | undefined; block: ContentBlock.ToolCall } | undefined;
33
+ const tools: Tool.Any[] = [];
34
+ return blocks
35
+ .filter((block) => block._tag === 'toolCall' || block._tag === 'toolResult' || block._tag === 'stats')
36
+ .map((block) => {
37
+ switch (block._tag) {
38
+ case 'toolCall': {
39
+ if (block.pending && lastToolCall?.block.toolCallId === block.toolCallId) {
40
+ return null;
41
+ }
42
+
43
+ const tool = tools.find((tool) => tool.name === block.name);
44
+ lastToolCall = { tool, block };
45
+ return {
46
+ title: tool?.description ?? [t('tool-call.label'), block.name].filter(Boolean).join(' '),
47
+ icon: block.operationIcon,
48
+ content: {
49
+ ...block,
50
+ input: safeParseJson(block.input),
51
+ },
52
+ };
53
+ }
54
+
55
+ case 'toolResult': {
56
+ // TODO(burdon): Parse error type.
57
+ if (block.error) {
58
+ return {
59
+ title: t('tool-error.label'),
60
+ icon: lastToolCall?.block.operationIcon,
61
+ content: block,
62
+ };
63
+ }
64
+
65
+ const title =
66
+ lastToolCall?.tool?.description ??
67
+ [t('tool-result.label'), lastToolCall?.block.name].filter(Boolean).join(' ');
68
+ const icon = lastToolCall?.block.operationIcon;
69
+ lastToolCall = undefined;
70
+ return {
71
+ title,
72
+ icon,
73
+ content: {
74
+ ...block,
75
+ result: typeof block.result === 'string' ? safeParseJson(block.result) : block.result,
76
+ },
77
+ };
78
+ }
79
+
80
+ case 'stats': {
81
+ if (!lastToolCall) {
82
+ return null;
83
+ }
84
+
85
+ return {
86
+ title: t('stats.label'),
87
+ icon: lastToolCall.block.operationIcon,
88
+ content: block,
89
+ };
90
+ }
91
+ }
92
+ })
93
+ .filter(isNonNullable);
94
+ }, [blocks, t]);
95
+
96
+ const handleChangeOpen = useCallback(() => {
97
+ setTimeout(() => {
98
+ // Measure after animation.
99
+ view?.requestMeasure();
100
+ }, 1_000);
101
+ }, [view]);
102
+
103
+ // Ignore if empty.
104
+ if (!items.length) {
105
+ return null;
106
+ }
107
+
108
+ return <ToolPanel items={items} onChangeOpen={handleChangeOpen} />;
109
+ };
110
+
111
+ type ToolPanelItem = { title: string; icon?: string; content: any };
112
+
113
+ type ToolPanelProps = {
114
+ items: ToolPanelItem[];
115
+ } & Pick<TogglePanelRootProps, 'onChangeOpen'>;
116
+
117
+ const DEFAULT_TOOL_ICON = 'ph--wrench--regular';
118
+
119
+ const ToolPanel = ({ items, onChangeOpen }: ToolPanelProps) => {
120
+ const tabsRef = useRef<HTMLDivElement>(null);
121
+ const [selected, setSelected] = useState(0);
122
+ const [open, setOpen] = useState(false);
123
+
124
+ // Clamp selected to avoid out-of-bounds after items shrink.
125
+ useEffect(() => {
126
+ setSelected((prev) => Math.min(prev, Math.max(0, items.length - 1)));
127
+ }, [items.length]);
128
+
129
+ useEffect(() => {
130
+ onChangeOpen?.(open);
131
+ if (open) {
132
+ tabsRef.current?.focus();
133
+ }
134
+ }, [open, onChangeOpen]);
135
+
136
+ const handleSelect = useCallback((index: number) => {
137
+ setSelected(index);
138
+ }, []);
139
+
140
+ // Prefer the icon from the latest tool call so the header reflects what's currently active.
141
+ // TextCrawl shows multiple titles, but only one icon slot — using the most recent keeps it in sync
142
+ // with the visible operation while it streams.
143
+ const headerIcon = items[items.length - 1]?.icon ?? items[0]?.icon ?? DEFAULT_TOOL_ICON;
144
+
145
+ return (
146
+ <TogglePanel.Root open={open} onChangeOpen={setOpen}>
147
+ <TogglePanel.Content>
148
+ <TogglePanel.Header classNames='flex items-center gap-2 text-sm'>
149
+ <div className='w-full grid grid-cols-[1fr_auto] items-center gap-2'>
150
+ <TextCrawl
151
+ key='status-roll'
152
+ classNames='text-description'
153
+ lines={items.map((item) => item.title)}
154
+ autoAdvance
155
+ greedy
156
+ />
157
+ <IconBlock>
158
+ <Icon icon={headerIcon} size={4} />
159
+ </IconBlock>
160
+ </div>
161
+ </TogglePanel.Header>
162
+ <TogglePanel.Body>
163
+ <TogglePanel.Viewport classNames='grid grid-cols-[32px_1fr]'>
164
+ <NumericTabs
165
+ ref={tabsRef}
166
+ classNames='p-1'
167
+ length={items.length}
168
+ selected={selected}
169
+ onSelect={handleSelect}
170
+ />
171
+ <JsonHighlighter
172
+ data={items[selected]?.content}
173
+ classNames='p-1 text-xs bg-transparent'
174
+ replacer={{
175
+ maxDepth: 3,
176
+ maxArrayLen: 10,
177
+ maxStringLen: 128,
178
+ }}
179
+ />
180
+ </TogglePanel.Viewport>
181
+ </TogglePanel.Body>
182
+ </TogglePanel.Content>
183
+ </TogglePanel.Root>
184
+ );
185
+ };
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export const styles = {
6
+ /**
7
+ * Vertical rhythm for block widgets. Padding, never margin: CodeMirror measures a widget from its own
8
+ * box, so a margin sits outside what it can see and its height model drifts from what is rendered —
9
+ * the error accumulates down the document until the turn-fold gutter markers are visibly misaligned.
10
+ * Note padding does not collapse between adjacent siblings the way margins do
11
+ */
12
+ padding: 'pt-2 pb-4',
13
+ border: 'border border-subdued-separator rounded-sm',
14
+ };
@@ -0,0 +1,45 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Kept out of `BranchWidget.tsx`: react-refresh only fast-refreshes a module whose
6
+ // exports are all components, so values exported beside them force a full page reload on
7
+ // every edit.
8
+
9
+ /**
10
+ * Relative ("5 min ago", "yesterday") within two days, absolute date beyond that. The exact
11
+ * timestamp is always available via the `title` tooltip.
12
+ *
13
+ * Recent prompts are the ones a reader places by elapsed time; older ones by date.
14
+ */
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+
17
+ export const formatTime = (created: string, now = Date.now()): string => {
18
+ const date = new Date(created);
19
+ if (Number.isNaN(date.getTime())) {
20
+ return '';
21
+ }
22
+
23
+ const elapsed = now - date.getTime();
24
+ if (elapsed < 0 || elapsed >= 2 * DAY_MS) {
25
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
26
+ }
27
+
28
+ const format = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
29
+ const minutes = Math.round(elapsed / 60_000);
30
+ if (minutes < 1) {
31
+ // Seconds rather than `format(0, 'minute')`, which renders as "this minute".
32
+ return format.format(-Math.max(1, Math.round(elapsed / 1000)), 'second');
33
+ }
34
+ if (minutes < 60) {
35
+ return format.format(-minutes, 'minute');
36
+ }
37
+
38
+ const hours = Math.round(minutes / 60);
39
+ if (hours < 24) {
40
+ return format.format(-hours, 'hour');
41
+ }
42
+
43
+ // `numeric: 'auto'` renders -1 day as "yesterday".
44
+ return format.format(-Math.round(hours / 24), 'day');
45
+ };
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './format-time';
6
+ export * from './FallbackWidget';
7
+ export * from './ReasoningWidget';
8
+ export * from './ReferenceWidget';
9
+ export * from './SelectWidget';
10
+ export * from './StatsWidget';
11
+ export * from './StatusWidget';
12
+ export * from './SuggestionWidget';
13
+ export * from './SummaryWidget';
14
+ export * from './ToolWidget';
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './components';
6
+ export * from './registry';
7
+ export * from './renderer';
8
+ export * from './translations';
9
+ export * from './types';
@@ -0,0 +1,144 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { AnchorWidget, type XmlWidgetRegistry, getXmlTextChild } from '@dxos/ui-editor';
6
+
7
+ import {
8
+ FallbackWidget,
9
+ ReasoningWidget,
10
+ ReferenceWidget,
11
+ SelectWidget,
12
+ StatsWidget,
13
+ StatusWidget,
14
+ SuggestionWidget,
15
+ SummaryWidget,
16
+ ToolWidget,
17
+ } from './components/widgets';
18
+
19
+ /**
20
+ * Floor for a portaled panel's collapsed height, reserved before its content paints. `heightMode:
21
+ * 'min'` makes it a floor rather than a pin — without it the row is measured at the height of an
22
+ * empty box and grows a frame later, a row moving under a reader scrolling up.
23
+ */
24
+ const COLLAPSED_HEIGHT = 42;
25
+
26
+ /**
27
+ * Widgets for the tags {@link createRenderer} emits.
28
+ *
29
+ * Host-extensible by spreading: the plugin adds its `surface` widget (which renders an
30
+ * app-framework Surface this package must not depend on) over the fallback registered here.
31
+ * DOM widgets render synchronously from the tag alone; React widgets are portaled and declare a
32
+ * collapsed-height floor so the row's first measurement is honest.
33
+ */
34
+ export const assistantRegistry: XmlWidgetRegistry = {
35
+ // No widget: the reader's own words stay in the document, where they can be selected and
36
+ // searched. Registered so the markdown parser keeps the tag as one block, and framed by the
37
+ // chrome, not here.
38
+ 'prompt': {
39
+ block: true,
40
+ },
41
+
42
+ //
43
+ // DOM widgets
44
+ //
45
+
46
+ 'link-preview': {
47
+ block: false,
48
+ urlSchemes: ['dxn:', 'echo:'],
49
+ factory: ({ label, dxn }) =>
50
+ typeof label === 'string' && typeof dxn === 'string' ? new AnchorWidget(label, dxn) : null,
51
+ },
52
+ // A "user"-turn message generated by the system (a trigger, an encoded event): meant to start the
53
+ // agent without reading as the reader's own prompt.
54
+ 'synthetic': {
55
+ block: true,
56
+ factory: ({ children, range }) => {
57
+ const text = getXmlTextChild(children ?? []);
58
+ return text ? new ReasoningWidget(text, range?.from) : null;
59
+ },
60
+ },
61
+ 'reasoning': {
62
+ block: true,
63
+ streaming: true,
64
+ factory: ({ children, range }) => {
65
+ const text = getXmlTextChild(children ?? []);
66
+ return text ? new ReasoningWidget(text, range?.from) : null;
67
+ },
68
+ },
69
+ 'status': {
70
+ block: true,
71
+ streaming: true,
72
+ factory: ({ children }) => {
73
+ const text = getXmlTextChild(children ?? []);
74
+ return text ? new StatusWidget(text) : null;
75
+ },
76
+ },
77
+ 'reference': {
78
+ block: false,
79
+ factory: ({ children, ref }) => {
80
+ const text = getXmlTextChild(children ?? []);
81
+ return text && typeof ref === 'string' ? new ReferenceWidget(text, ref) : null;
82
+ },
83
+ },
84
+ // Inline so a run of suggestions flows onto one wrapped line instead of one block per line.
85
+ 'suggestion': {
86
+ block: false,
87
+ factory: ({ children }) => {
88
+ const text = getXmlTextChild(children ?? []);
89
+ return text ? new SuggestionWidget(text) : null;
90
+ },
91
+ },
92
+ 'select': {
93
+ block: true,
94
+ factory: ({ children }) => {
95
+ const options = (children ?? [])
96
+ .map((option: any) => option?._tag === 'option' && getXmlTextChild(option.children))
97
+ .filter(Boolean) as string[];
98
+ return options.length ? new SelectWidget(options) : null;
99
+ },
100
+ },
101
+ 'stats': {
102
+ block: true,
103
+ factory: ({ children }) => {
104
+ const text = getXmlTextChild(children ?? []);
105
+ return text ? new StatsWidget(text) : null;
106
+ },
107
+ },
108
+
109
+ //
110
+ // React widgets (portaled outside the editor)
111
+ //
112
+
113
+ 'toolkit': {
114
+ block: true,
115
+ streaming: true,
116
+ estimatedHeight: () => COLLAPSED_HEIGHT,
117
+ heightMode: 'min',
118
+ Component: ToolWidget,
119
+ },
120
+ 'summary': {
121
+ block: true,
122
+ estimatedHeight: () => COLLAPSED_HEIGHT,
123
+ heightMode: 'min',
124
+ Component: SummaryWidget,
125
+ },
126
+ // The host overrides this with a widget that can actually dispatch the surface.
127
+ 'surface': {
128
+ block: true,
129
+ estimatedHeight: () => COLLAPSED_HEIGHT,
130
+ heightMode: 'min',
131
+ Component: FallbackWidget,
132
+ },
133
+
134
+ //
135
+ // Fallback
136
+ //
137
+
138
+ 'json': {
139
+ block: true,
140
+ estimatedHeight: () => COLLAPSED_HEIGHT,
141
+ heightMode: 'min',
142
+ Component: FallbackWidget,
143
+ },
144
+ } as const;
@@ -0,0 +1,216 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type URI } from '@dxos/keys';
6
+ import { type MessageRenderer } from '@dxos/react-ui-feed';
7
+ import { type ContentBlock, type Message } from '@dxos/types';
8
+
9
+ import { type ChatView } from './types';
10
+
11
+ export type CreateRendererOptions = {
12
+ /** Resolves a reference's display label; the tag carries the DXN either way. */
13
+ getObjectLabel?: (uri: URI.URI) => string;
14
+ };
15
+
16
+ /**
17
+ * The assistant's projection of a message: each block flattened to markdown, everything that is not
18
+ * prose emitted as an XML tag for the registry's widgets. The view type is a filter over blocks —
19
+ * the model always carries everything, so switching views is a re-render, not a re-fetch.
20
+ *
21
+ * Consecutive tool blocks (`toolCall`/`toolResult`/`stats`) render as ONE `<toolkit>` tag carrying
22
+ * the run as JSON: the panel widget shows a run of calls as tabs, and the item is rebuilt from its
23
+ * message alone — there is no side channel accumulating widget state (the old `MessageSyncer`
24
+ * machinery this package retires).
25
+ */
26
+ export const createRenderer = (
27
+ viewType: ChatView | undefined,
28
+ { getObjectLabel = () => 'Object' }: CreateRendererOptions = {},
29
+ ): MessageRenderer => {
30
+ return (message) => {
31
+ const blocks = message.blocks.filter((block) => isBlockVisible(viewType, block));
32
+ const segments: string[] = [];
33
+ let tools: ContentBlock.Any[] = [];
34
+
35
+ const flushTools = () => {
36
+ if (tools.length) {
37
+ segments.push(toolkitTag(tools));
38
+ tools = [];
39
+ }
40
+ };
41
+
42
+ for (const block of blocks) {
43
+ if (block._tag === 'toolCall' || block._tag === 'toolResult' || (block._tag === 'stats' && tools.length)) {
44
+ tools.push(block);
45
+ continue;
46
+ }
47
+
48
+ flushTools();
49
+ const rendered = blockToMarkdown(message, block, getObjectLabel);
50
+ if (rendered) {
51
+ segments.push(rendered);
52
+ }
53
+ }
54
+ flushTools();
55
+
56
+ // Suggestions are inline widgets meant to flow: a run of them joins on one line and wraps as
57
+ // chips; everything else is separated by a blank line so each block parses as its own
58
+ // markdown block.
59
+ const text = segments
60
+ .reduce<string[]>((parts, segment) => {
61
+ const previous = parts[parts.length - 1];
62
+ if (previous?.startsWith('<suggestion') && segment.startsWith('<suggestion')) {
63
+ parts[parts.length - 1] = `${previous} ${segment}`;
64
+ } else {
65
+ parts.push(segment);
66
+ }
67
+ return parts;
68
+ }, [])
69
+ .join('\n\n');
70
+
71
+ return { kind: 'markdown', text };
72
+ };
73
+ };
74
+
75
+ const isBlockVisible = (viewType: ChatView | undefined, block: ContentBlock.Any): boolean => {
76
+ switch (viewType) {
77
+ case 'debug':
78
+ return true;
79
+ case 'summary':
80
+ // Only conversational text; hide reasoning, tool calls, status, stats and synthetic turns.
81
+ return block._tag === 'text' && (block as ContentBlock.Text).disposition !== 'synthetic';
82
+ case 'normal':
83
+ return block._tag !== 'reasoning';
84
+ case 'thinking':
85
+ default:
86
+ return true;
87
+ }
88
+ };
89
+
90
+ const blockToMarkdown = (
91
+ message: Message.Message,
92
+ block: ContentBlock.Any,
93
+ getObjectLabel: (uri: URI.URI) => string,
94
+ ): string | undefined => {
95
+ switch (block._tag) {
96
+ case 'text': {
97
+ if (message.sender.role === 'user') {
98
+ // A synthetic turn is system-generated input (a trigger, an encoded event): shown like
99
+ // reasoning, not framed as the reader's own words.
100
+ return block.disposition === 'synthetic'
101
+ ? tag('synthetic', block.text, block)
102
+ : tag('prompt', block.text, block);
103
+ }
104
+ return block.text.trim() || undefined;
105
+ }
106
+
107
+ case 'reasoning':
108
+ return tag('reasoning', block.reasoningText ?? block.redactedText ?? '', block);
109
+
110
+ case 'status':
111
+ return tag('status', block.statusText, block);
112
+
113
+ case 'summary':
114
+ return tag('summary', block.content, block);
115
+
116
+ case 'suggestion':
117
+ return block.pending ? undefined : tag('suggestion', block.text, block);
118
+
119
+ case 'select':
120
+ return block.pending || !block.options.length
121
+ ? undefined
122
+ : `<select>${block.options.map((option) => `<option>${escapeXml(option)}</option>`).join('')}</select>`;
123
+
124
+ case 'reference': {
125
+ if (block.pending) {
126
+ return undefined;
127
+ }
128
+ const uri = block.reference.uri;
129
+ return `<reference ref="${escapeAttribute(uri.toString())}">${escapeXml(getObjectLabel(uri))}</reference>`;
130
+ }
131
+
132
+ case 'stats':
133
+ // Only meaningful inside a tool run (grouped by the caller); bare stats render nothing.
134
+ return undefined;
135
+
136
+ case 'surface':
137
+ return block.pending
138
+ ? undefined
139
+ : `<surface role="${escapeAttribute(block.role)}">${escapeXml(JSON.stringify(block.data ?? {}))}</surface>`;
140
+
141
+ default:
142
+ // Nothing renders blank: an unknown block is shown as what it is.
143
+ return tag('json', JSON.stringify(block), block);
144
+ }
145
+ };
146
+
147
+ /** A run of tool blocks as one tag; the widget parses the payload back out. */
148
+ const toolkitTag = (blocks: ContentBlock.Any[]): string => {
149
+ const pending = blocks.some((block) => block.pending);
150
+ return `<toolkit${pending ? ' pending="true"' : ''}>${escapeXml(JSON.stringify(blocks))}</toolkit>`;
151
+ };
152
+
153
+ /**
154
+ * An XML tag holding block content.
155
+ *
156
+ * A pending block is emitted **unclosed**, which is the streaming contract: the item reconciles by
157
+ * appending, so a closing tag written before the content is complete would be rewritten per chunk.
158
+ * Paragraph breaks inside tag content are collapsed, since a blank line would end the enclosing
159
+ * markdown block and leave the rest of the tag parsing as prose.
160
+ */
161
+ const tag = (name: string, content: string, block: ContentBlock.Any): string => {
162
+ const text = escapeXml(content.replace(/\n\n+/g, ' ').trim());
163
+ if (!text.length && !block.pending) {
164
+ return '';
165
+ }
166
+
167
+ const open = `<${name}>`;
168
+ return block.pending ? `${open}${text}` : `${open}${text}</${name}>`;
169
+ };
170
+
171
+ const escapeXml = (raw: string): string => raw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
172
+ const escapeAttribute = (raw: string): string => escapeXml(raw).replace(/"/g, '&quot;');
173
+
174
+ //
175
+ // Estimates
176
+ //
177
+
178
+ /** Chrome around every row: the padding and the hover toolbar's reserved line. */
179
+ const ROW_CHROME = 46;
180
+ /** Height of a wrapped line of body text, and how many characters fit at typical thread width. */
181
+ const LINE_HEIGHT = 24;
182
+ const LINE_CHARS = 90;
183
+ /** A collapsed panel: reasoning, a tool run, a summary. */
184
+ const PANEL_HEIGHT = 50;
185
+
186
+ /**
187
+ * What a row will measure, from the message alone. Rough on purpose — its only job is to be close
188
+ * enough that measuring the row does not move the rows below it.
189
+ */
190
+ export const estimateRow = (message: Message.Message): number => {
191
+ let height = ROW_CHROME;
192
+ for (const block of message.blocks) {
193
+ switch (block._tag) {
194
+ case 'text': {
195
+ for (const paragraph of block.text.split('\n\n')) {
196
+ height += Math.max(1, Math.ceil(paragraph.length / LINE_CHARS)) * LINE_HEIGHT;
197
+ }
198
+ break;
199
+ }
200
+ case 'suggestion':
201
+ height += LINE_HEIGHT + 8;
202
+ break;
203
+ case 'select':
204
+ height += block.options.length * (LINE_HEIGHT + 10);
205
+ break;
206
+ case 'toolResult':
207
+ case 'stats':
208
+ // Folded into the preceding call's panel.
209
+ break;
210
+ default:
211
+ height += PANEL_HEIGHT;
212
+ }
213
+ }
214
+
215
+ return height;
216
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './test-generator';