@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.
- package/LICENSE +105 -0
- package/dist/lib/index.mjs +839 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +214 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/ChatThread/ChatThread.d.ts +52 -0
- package/dist/types/src/components/ChatThread/ChatThread.d.ts.map +1 -0
- package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts +38 -0
- package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts.map +1 -0
- package/dist/types/src/components/ChatThread/index.d.ts +2 -0
- package/dist/types/src/components/ChatThread/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageChrome/MessageChrome.d.ts +28 -0
- package/dist/types/src/components/MessageChrome/MessageChrome.d.ts.map +1 -0
- package/dist/types/src/components/MessageChrome/index.d.ts +2 -0
- package/dist/types/src/components/MessageChrome/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/components/widgets/FallbackWidget.d.ts +7 -0
- package/dist/types/src/components/widgets/FallbackWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ReasoningWidget.d.ts +25 -0
- package/dist/types/src/components/widgets/ReasoningWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts +18 -0
- package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ReferenceWidget.d.ts +9 -0
- package/dist/types/src/components/widgets/ReferenceWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/SelectWidget.d.ts +14 -0
- package/dist/types/src/components/widgets/SelectWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/StatsWidget.d.ts +12 -0
- package/dist/types/src/components/widgets/StatsWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/StatusWidget.d.ts +12 -0
- package/dist/types/src/components/widgets/StatusWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/SuggestionWidget.d.ts +11 -0
- package/dist/types/src/components/widgets/SuggestionWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/SummaryWidget.d.ts +4 -0
- package/dist/types/src/components/widgets/SummaryWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ToolWidget.d.ts +10 -0
- package/dist/types/src/components/widgets/ToolWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ToolWidget.stories.d.ts +31 -0
- package/dist/types/src/components/widgets/ToolWidget.stories.d.ts.map +1 -0
- package/dist/types/src/components/widgets/defaults.d.ts +11 -0
- package/dist/types/src/components/widgets/defaults.d.ts.map +1 -0
- package/dist/types/src/components/widgets/format-time.d.ts +2 -0
- package/dist/types/src/components/widgets/format-time.d.ts.map +1 -0
- package/dist/types/src/components/widgets/index.d.ts +11 -0
- package/dist/types/src/components/widgets/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/registry.d.ts +11 -0
- package/dist/types/src/registry.d.ts.map +1 -0
- package/dist/types/src/renderer.d.ts +25 -0
- package/dist/types/src/renderer.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/test-generator.d.ts +7 -0
- package/dist/types/src/testing/test-generator.d.ts.map +1 -0
- package/dist/types/src/testing/test-generator.test.d.ts +2 -0
- package/dist/types/src/testing/test-generator.test.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +19 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +28 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +67 -0
- package/src/components/ChatThread/ChatThread.stories.tsx +419 -0
- package/src/components/ChatThread/ChatThread.tsx +188 -0
- package/src/components/ChatThread/index.ts +5 -0
- package/src/components/MessageChrome/MessageChrome.tsx +147 -0
- package/src/components/MessageChrome/index.ts +5 -0
- package/src/components/index.ts +7 -0
- package/src/components/widgets/FallbackWidget.tsx +26 -0
- package/src/components/widgets/ReasoningWidget.stories.tsx +68 -0
- package/src/components/widgets/ReasoningWidget.ts +139 -0
- package/src/components/widgets/ReferenceWidget.ts +28 -0
- package/src/components/widgets/SelectWidget.ts +39 -0
- package/src/components/widgets/StatsWidget.ts +31 -0
- package/src/components/widgets/StatusWidget.ts +53 -0
- package/src/components/widgets/SuggestionWidget.ts +50 -0
- package/src/components/widgets/SummaryWidget.tsx +27 -0
- package/src/components/widgets/ToolWidget.stories.tsx +55 -0
- package/src/components/widgets/ToolWidget.tsx +185 -0
- package/src/components/widgets/defaults.ts +14 -0
- package/src/components/widgets/format-time.ts +45 -0
- package/src/components/widgets/index.ts +14 -0
- package/src/index.ts +9 -0
- package/src/registry.tsx +144 -0
- package/src/renderer.ts +216 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/test-generator.test.ts +75 -0
- package/src/testing/test-generator.ts +280 -0
- package/src/translations.ts +27 -0
- 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
package/src/registry.tsx
ADDED
|
@@ -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;
|
package/src/renderer.ts
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
172
|
+
const escapeAttribute = (raw: string): string => escapeXml(raw).replace(/"/g, '"');
|
|
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
|
+
};
|