@adecore/agents-react 0.27.4 → 0.28.0

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.
@@ -1,6 +1,6 @@
1
1
  import { ChartRenderer } from './renderers/chart.js';
2
2
  import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices.js';
3
- import { CodeBlockRenderer, CompareRenderer, GalleryRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content.js';
3
+ import { CodeBlockRenderer, CompareRenderer, GalleryRenderer, ImageRenderer, ModelRenderer, SourceRenderer, SourcesRenderer } from './renderers/content.js';
4
4
  import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data.js';
5
5
  import { ButtonRenderer, ChecklistRenderer, ItemRenderer, SegmentedRenderer, SelectRenderer, SliderRenderer, SwitchRenderer, TextFieldRenderer } from './renderers/inputs.js';
6
6
  import { CommitRenderer, DiffRenderer, FileRenderer, NodeRenderer } from './renderers/links.js';
@@ -37,6 +37,7 @@ export const UI_RENDERERS = {
37
37
  Image: ImageRenderer,
38
38
  Gallery: GalleryRenderer,
39
39
  Compare: CompareRenderer,
40
+ Model: ModelRenderer,
40
41
  Map: MapRenderer,
41
42
  Place: null,
42
43
  Sources: SourcesRenderer,
@@ -4,6 +4,7 @@ export declare function CodeBlockRenderer({ node }: UiRendererProps<UiProps<'Cod
4
4
  export declare function ImageRenderer({ node, children, context }: UiRendererProps<UiProps<'Image'>>): import("react").JSX.Element;
5
5
  export declare function GalleryRenderer({ node, children }: UiRendererProps<UiProps<'Gallery'>>): import("react").JSX.Element;
6
6
  export declare function CompareRenderer({ node, children, context }: UiRendererProps<UiProps<'Compare'>>): import("react").JSX.Element;
7
+ export declare function ModelRenderer({ node, children, context }: UiRendererProps<UiProps<'Model'>>): import("react").JSX.Element;
7
8
  export declare function SourcesRenderer({ node, children }: UiRendererProps<UiProps<'Sources'>>): import("react").JSX.Element;
8
9
  export interface UiSourceCitationProps {
9
10
  number: number;
@@ -2,7 +2,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import clsx from 'clsx';
3
3
  import { createContext, useContext, useId, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
5
+ import { Box } from 'lucide-react';
6
+ import { ErrorBoundary, Icon, Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
6
7
  import { formatNumber } from '@adecore/ui/format';
7
8
  import { useChatHost } from '../../../../host.js';
8
9
  import { CodeBlock } from '../../CodeBlock.js';
@@ -95,6 +96,31 @@ export function CompareRenderer({ node, children, context }) {
95
96
  function CompareChip({ side, label }) {
96
97
  return (_jsx("span", { className: clsx('pointer-events-none absolute top-2 z-20 max-w-2/5 truncate rounded-md bg-surface-raised', side === 'left' ? 'left-2' : 'right-2'), children: _jsx(Pill, { shape: "tag", size: "sm", tone: "muted", children: label }) }));
97
98
  }
99
+ /*
100
+ * The host draws the model, since the chat knows no 3D format. The frame has a fixed height in the reply column,
101
+ * so a view that loads late never moves the thread. Without a view of the host, or without the bytes, the model
102
+ * stays a quiet row that names it.
103
+ */
104
+ export function ModelRenderer({ node, children, context }) {
105
+ const { t } = useTranslation('agent-chat');
106
+ const host = useChatHost();
107
+ const { attachment, alt } = node.props;
108
+ const source = host.attachments.useUrl(context.scopeId, context.chatId, attachment);
109
+ const caption = uiNodeLabel(node);
110
+ const label = alt ?? (caption === '' ? t('blocks.model.label') : caption);
111
+ const View = host.ModelView;
112
+ let body;
113
+ if (View !== undefined && source.url !== null) {
114
+ body = (_jsx("div", { role: "group", "aria-label": label, className: "relative h-80 w-full overflow-hidden rounded-lg border border-border bg-surface-sunken", children: _jsx(ErrorBoundary, { label: t('blocks.model.crashed'), resetKeys: [attachment, source.url], compact: true, children: _jsx(View, { scopeId: context.scopeId, chatId: context.chatId, attachmentId: attachment, url: source.url, label: label, alt: alt, caption: caption }) }) }));
115
+ }
116
+ else if (View !== undefined && source.failure === null) {
117
+ body = _jsx("div", { "aria-busy": "true", "aria-label": label, className: "h-80 w-full rounded-lg border border-border bg-surface-sunken" });
118
+ }
119
+ else {
120
+ body = (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs", children: [_jsx(Icon, { icon: Box, size: 14, className: "shrink-0 text-text-muted" }), _jsx("span", { className: "min-w-0 truncate text-text", children: alt ?? attachment }), _jsx("span", { className: "ml-auto shrink-0 text-text-faint", children: source.failure === null ? t('blocks.model.label') : t('blocks.model.unavailable') })] }));
121
+ }
122
+ return (_jsxs("figure", { className: "flex flex-col items-start gap-1.5 px-2", children: [body, caption !== '' && _jsx("figcaption", { className: "text-xs text-text-muted", children: children })] }));
123
+ }
98
124
  export function SourcesRenderer({ node, children }) {
99
125
  const { t } = useTranslation('agent-chat');
100
126
  const headingId = useId();
@@ -185,7 +185,7 @@ export function ThinkingRow({ chatId, item: derived }) {
185
185
  const preview = useMemo(() => (item.streaming ? '' : thoughtPreview(item.text)), [item.streaming, item.text]);
186
186
  const duration = formatElapsedShort((item.endedAt ?? item.createdAt) - item.createdAt);
187
187
  useOpenForFind(item.id, 'text', setOpen);
188
- return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsxs("button", { className: "flex w-full min-w-0 items-center gap-2 text-left text-xs text-text-muted hover:text-text", disabled: item.streaming, "aria-expanded": item.streaming ? undefined : open, onClick: () => setOpen((o) => !o), children: [_jsx("span", { className: ROW_GUTTER, children: _jsx(Icon, { icon: Brain, size: 12 }) }), item.streaming ? (_jsx("span", { className: "chat-live-text min-w-0 truncate", children: live || t('rows.thinking.live') })) : preview === '' ? (_jsxs(_Fragment, { children: [_jsx("span", { children: t('rows.thinking.thoughtFor', { duration }) }), _jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('shrink-0 transition-transform', open && 'rotate-180') })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 truncate", children: open ? t('rows.thinking.thought') : preview }), _jsx("span", { className: "grow" }), _jsx("span", { className: "shrink-0 text-text-faint tabular-nums", children: duration }), _jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('shrink-0 transition-transform', open && 'rotate-180') })] }))] }), shown && text !== '' && (_jsx("div", { "data-find-field": "text", className: "mt-1 border-l border-border pl-2.5 select-text", children: reveal.active ? (_jsx("div", { className: "text-sm whitespace-pre-wrap text-text-faint", children: _jsx(FadingWords, { text: reveal.text }) })) : (_jsx(Markdown, { text: text, muted: true })) }))] }));
188
+ return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsxs("button", { className: "flex w-full min-w-0 items-center gap-2 text-left text-xs text-text-muted hover:text-text", disabled: item.streaming, "aria-expanded": item.streaming ? undefined : open, onClick: () => setOpen((o) => !o), children: [_jsx("span", { className: ROW_GUTTER, children: _jsx(Icon, { icon: Brain, size: 12 }) }), item.streaming ? (_jsx("span", { className: "chat-live-text min-w-0 truncate", children: live || t('rows.thinking.live') })) : preview === '' ? (_jsxs(_Fragment, { children: [_jsx("span", { children: t('rows.thinking.thoughtFor', { duration }) }), _jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('shrink-0 transition-transform', open && 'rotate-180') })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 truncate", children: open ? t('rows.thinking.thought') : preview }), _jsx("span", { className: "grow" }), _jsx("span", { className: "shrink-0 text-text-faint tabular-nums", children: duration }), _jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('shrink-0 transition-transform', open && 'rotate-180') })] }))] }), shown && text !== '' && (_jsx("div", { "data-find-field": "text", className: "mt-1 ml-1.75 border-l border-border pl-4 select-text", children: reveal.active ? (_jsx("div", { className: "text-sm whitespace-pre-wrap text-text-faint", children: _jsx(FadingWords, { text: reveal.text }) })) : (_jsx(Markdown, { text: text, muted: true })) }))] }));
189
189
  }
190
190
  /*
191
191
  * The latest thought of a running turn over the call it led to, as its heading or first sentence,
@@ -257,7 +257,7 @@ export function ApprovalHistoryRow({ item }) {
257
257
  if (item.decision === 'pending') {
258
258
  return null;
259
259
  }
260
- return (_jsxs("div", { className: "-mx-1 mb-0.5 flex h-7 items-center gap-2 px-1 text-xs text-text-faint", children: [_jsx("span", { className: clsx(ROW_GUTTER, item.decision === 'deny' && 'text-status-error'), children: DECISION_ICON[item.decision] }), _jsx("span", { className: clsx(item.decision === 'deny' && 'text-status-error'), children: t(`rows.approval.${item.decision}`) }), _jsx("span", { className: "text-text-muted", children: item.toolName }), _jsx("span", { className: "min-w-0 truncate font-mono", children: toolSummaryIsPath(item.toolName, item.input) ? (_jsx(MiddleTruncate, { text: toolSummary(item.toolName, item.input) })) : (toolSummary(item.toolName, item.input)) })] }));
260
+ return (_jsxs("div", { className: "-mx-1 mb-0.5 flex h-7 items-center gap-2 px-1 text-xs text-text-faint", children: [_jsx("span", { className: clsx(ROW_GUTTER, item.decision === 'deny' && 'text-status-error'), children: DECISION_ICON[item.decision] }), _jsx("span", { className: clsx(item.decision === 'deny' && 'text-status-error'), children: t(`rows.approval.${item.decision}`) }), _jsx("span", { className: "text-text-muted", children: item.toolName }), _jsx("span", { className: "min-w-0 truncate font-mono text-code", children: toolSummaryIsPath(item.toolName, item.input) ? (_jsx(MiddleTruncate, { text: toolSummary(item.toolName, item.input) })) : (toolSummary(item.toolName, item.input)) })] }));
261
261
  }
262
262
  export function QuestionHistoryRow({ item }) {
263
263
  const { t } = useTranslation('agent-chat');
@@ -10,6 +10,7 @@ import { useChatScope } from '../../../scope.js';
10
10
  import { useChats } from '../../../state/chats.js';
11
11
  import { useProviders } from '../../../state/providers.js';
12
12
  import { taskRowStateOf } from '../../subagent-list.js';
13
+ import { ROW_GUTTER } from '../icons.js';
13
14
  const DOT = {
14
15
  running: 'bg-status-running',
15
16
  waiting: 'bg-status-needs-you',
@@ -34,7 +35,7 @@ function StateLine({ state, task }) {
34
35
  const { t } = useTranslation('agent-chat');
35
36
  const until = state === 'paused' ? task?.paused?.until : undefined;
36
37
  const now = useNow(60_000, until === undefined);
37
- return (_jsx("span", { className: clsx('truncate text-xs', STATE_TONE[state]), children: until === undefined ? t(`rows.task.${state}`) : t('rows.task.pausedUntil', { time: formatMoment(until, now) }) }));
38
+ return (_jsx("span", { className: clsx('truncate text-2xs', STATE_TONE[state]), children: until === undefined ? t(`rows.task.${state}`) : t('rows.task.pausedUntil', { time: formatMoment(until, now) }) }));
38
39
  }
39
40
  /*
40
41
  * The row of a task given to another node: that agent's mark and model, what the task is about and
@@ -59,5 +60,5 @@ export function TaskRow({ item, task, open, onPress }) {
59
60
  const startedAt = task?.createdAt ?? item.startedAt;
60
61
  const finishedAt = task === null ? item.finishedAt : (task.settledAt ?? item.finishedAt);
61
62
  const settled = state === 'done' || state === 'failed' || state === 'cancelled';
62
- return (_jsxs("button", { className: "-mx-1 mb-0.5 flex w-[calc(100%+8px)] items-center gap-2.5 rounded-md px-1 py-1 text-left hover:bg-surface-hover", onClick: onPress, children: [_jsxs("span", { className: "relative grid size-7 shrink-0 place-items-center rounded-full bg-surface-raised text-text", children: [provider === undefined ? _jsx(Icon, { icon: Bot, size: 14 }) : _jsx(AgentIcon, { kind: provider, size: 14 }), _jsx("span", { className: clsx('absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-surface', DOT[state]) })] }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [_jsx("span", { "data-find-field": "summary", className: "truncate text-xs font-medium text-text", children: model === null ? title : t('rows.task.named', { model, title }) }), _jsx(StateLine, { state: state, task: task })] }), (state === 'running' || state === 'waiting') && startedAt > 0 && _jsx(Elapsed, { startedAt: startedAt }), settled && startedAt > 0 && finishedAt !== null && finishedAt >= startedAt && (_jsx("span", { className: "shrink-0 text-xs text-text-faint tabular-nums", children: formatElapsedShort(finishedAt - startedAt) })), _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') })] }));
63
+ return (_jsxs("button", { className: "-mx-1 mb-0.5 flex w-[calc(100%+8px)] items-start gap-2 rounded-md px-1 py-1 text-left hover:bg-surface-hover", onClick: onPress, children: [_jsxs("span", { className: clsx(ROW_GUTTER, 'relative text-text'), children: [provider === undefined ? _jsx(Icon, { icon: Bot, size: 14 }) : _jsx(AgentIcon, { kind: provider, size: 14 }), _jsx("span", { className: clsx('absolute -right-0.75 bottom-0 size-2 rounded-full ring-2 ring-surface', DOT[state]) })] }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [_jsx("span", { "data-find-field": "summary", className: "truncate text-xs font-medium text-text", children: model === null ? title : t('rows.task.named', { model, title }) }), _jsx(StateLine, { state: state, task: task })] }), _jsxs("span", { className: "flex h-5 shrink-0 items-center gap-2", children: [(state === 'running' || state === 'waiting') && startedAt > 0 && _jsx(Elapsed, { startedAt: startedAt }), settled && startedAt > 0 && finishedAt !== null && finishedAt >= startedAt && (_jsx("span", { className: "shrink-0 text-xs text-text-faint tabular-nums", children: formatElapsedShort(finishedAt - startedAt) })), _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') })] })] }));
63
64
  }
@@ -76,7 +76,7 @@ export function ToggleLine({ icon, label, brand, detail, command, look, path, op
76
76
  const classes = clsx(
77
77
  // Fix the width so long commands reach the truncation inside the negative margins.
78
78
  'group/line -mx-1 mb-0.5 flex h-7 w-[calc(100%+8px)] items-center gap-2 rounded-md px-1 text-left text-xs text-text-muted', onToggle !== undefined && 'hover:bg-surface-hover', failed && 'text-status-error', className);
79
- const content = (_jsxs(_Fragment, { children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: icon }), _jsxs("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: [brand !== undefined && _jsx("span", { className: BRAND, children: brand }), brand !== undefined && label !== '' && ' ', label] }), look !== undefined ? (_jsxs(_Fragment, { children: [look.run.detail !== '' && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: _jsx(LookDetail, { run: look.run, chatId: look.chatId }) })), _jsx(LookTags, { run: look.run })] })) : (detail && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: command ? _jsx(ShellCommand, { command: detail }) : path ? _jsx(MiddleTruncate, { text: detail }) : detail }))), _jsx("span", { className: "grow" }), trailing, time !== undefined && _jsx(RowTime, { at: time }), failed && _jsx(Icon, { icon: X, size: 12, className: "shrink-0" }), onToggle !== undefined && (_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') }))] }));
79
+ const content = (_jsxs(_Fragment, { children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: icon }), _jsxs("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: [brand !== undefined && _jsx("span", { className: BRAND, children: brand }), brand !== undefined && label !== '' && ' ', label] }), look !== undefined ? (_jsxs(_Fragment, { children: [look.run.detail !== '' && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-code text-text-faint", children: _jsx(LookDetail, { run: look.run, chatId: look.chatId }) })), _jsx(LookTags, { run: look.run })] })) : (detail && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-code text-text-faint", children: command ? _jsx(ShellCommand, { command: detail }) : path ? _jsx(MiddleTruncate, { text: detail }) : detail }))), _jsx("span", { className: "grow" }), trailing, time !== undefined && _jsx(RowTime, { at: time }), failed && _jsx(Icon, { icon: X, size: 12, className: "shrink-0" }), onToggle !== undefined && (_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') }))] }));
80
80
  if (onToggle === undefined) {
81
81
  return _jsx("div", { className: classes, children: content });
82
82
  }
package/dist/host.d.ts CHANGED
@@ -162,6 +162,15 @@ export type UiMapSource = {
162
162
  state: 'off';
163
163
  reason: 'disabled' | 'signed-out' | 'unavailable';
164
164
  };
165
+ export interface UiModelViewProps {
166
+ scopeId: string;
167
+ chatId: string;
168
+ attachmentId: string;
169
+ url: string;
170
+ label: string;
171
+ alt?: string;
172
+ caption: string;
173
+ }
165
174
  export interface ChatHost {
166
175
  intelligentUi?: {
167
176
  subscribe?(scopeId: string, chatId: string, changed: () => void): () => void;
@@ -195,6 +204,7 @@ export interface ChatHost {
195
204
  ReadImage: ComponentType<{
196
205
  path: string;
197
206
  }> | null;
207
+ ModelView?: ComponentType<UiModelViewProps>;
198
208
  MarkdownImage?: ComponentType<{
199
209
  path: string;
200
210
  alt: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { type ChatActions, type FileRef, type ShellCodeBlockContext, type ChatPlace, type SubagentTask, type TimelineFindOptions, type TimelineFind, type ComposerDictationProps, type ComposerSlotProps, type SubagentSlotProps, type ThreadCard, type VisualHost, type ReplyAuthor, type ChatHost, type UiMapSource, type CommandLook, type CommandLookMatch, type CommandName, ChatHostProvider, useChatHost, type AccentChoice, type ChatToast, type TextareaProps, type ResourceUrl } from './host.js';
1
+ export { type ChatActions, type FileRef, type ShellCodeBlockContext, type ChatPlace, type SubagentTask, type TimelineFindOptions, type TimelineFind, type ComposerDictationProps, type ComposerSlotProps, type SubagentSlotProps, type ThreadCard, type VisualHost, type ReplyAuthor, type ChatHost, type UiMapSource, type UiModelViewProps, type CommandLook, type CommandLookMatch, type CommandName, ChatHostProvider, useChatHost, type AccentChoice, type ChatToast, type TextareaProps, type ResourceUrl } from './host.js';
2
2
  export { type ChatScope, ChatScopeContext, useChatScope } from './scope.js';
3
3
  export { type ChatRequestMap, type ChatEventMap, type ChatTransportStatus, type ChatTransport, ChatTransportError, errorCode, isConnectionError } from './transport.js';
4
4
  export { portTransport, type PortTransport } from './port-transport.js';
@@ -621,6 +621,11 @@ declare const _default: {
621
621
  label: string;
622
622
  unavailable: string;
623
623
  };
624
+ model: {
625
+ label: string;
626
+ unavailable: string;
627
+ crashed: string;
628
+ };
624
629
  sources: string;
625
630
  citation: string;
626
631
  outsideProject: string;
@@ -621,6 +621,11 @@ export default {
621
621
  label: 'Image',
622
622
  unavailable: 'The image is not available'
623
623
  },
624
+ model: {
625
+ label: '3D model',
626
+ unavailable: 'The model is not available',
627
+ crashed: 'This model failed to render'
628
+ },
624
629
  sources: 'Sources',
625
630
  citation: 'Source {{number}}: {{title}}',
626
631
  outsideProject: 'Outside the project, shown as text',
@@ -621,6 +621,11 @@ declare const _default: {
621
621
  label: string;
622
622
  unavailable: string;
623
623
  };
624
+ model: {
625
+ label: string;
626
+ unavailable: string;
627
+ crashed: string;
628
+ };
624
629
  sources: string;
625
630
  citation: string;
626
631
  outsideProject: string;
@@ -621,6 +621,11 @@ export default {
621
621
  label: 'Afbeelding',
622
622
  unavailable: 'De afbeelding is niet beschikbaar'
623
623
  },
624
+ model: {
625
+ label: '3D-model',
626
+ unavailable: 'Het model is niet beschikbaar',
627
+ crashed: 'Dit model kon niet worden getekend'
628
+ },
624
629
  sources: 'Bronnen',
625
630
  citation: 'Bron {{number}}: {{title}}',
626
631
  outsideProject: 'Buiten het project, als tekst getoond',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.27.4",
71
- "@adecore/editor": "0.27.4",
72
- "@adecore/editor-react": "0.27.4",
73
- "@adecore/intelligent-ui": "0.27.4",
74
- "@adecore/merge": "0.27.4",
70
+ "@adecore/agent-contracts": "0.28.0",
71
+ "@adecore/editor": "0.28.0",
72
+ "@adecore/editor-react": "0.28.0",
73
+ "@adecore/intelligent-ui": "0.28.0",
74
+ "@adecore/merge": "0.28.0",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.27.4",
97
+ "@adecore/ui": "^0.28.0",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.27.4",
104
+ "@adecore/ui": "0.28.0",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -1,7 +1,7 @@
1
1
  import type { UiRenderers } from './render-context';
2
2
  import { ChartRenderer } from './renderers/chart';
3
3
  import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices';
4
- import { CodeBlockRenderer, CompareRenderer, GalleryRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content';
4
+ import { CodeBlockRenderer, CompareRenderer, GalleryRenderer, ImageRenderer, ModelRenderer, SourceRenderer, SourcesRenderer } from './renderers/content';
5
5
  import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data';
6
6
  import {
7
7
  ButtonRenderer,
@@ -57,6 +57,7 @@ export const UI_RENDERERS: UiRenderers = {
57
57
  Image: ImageRenderer,
58
58
  Gallery: GalleryRenderer,
59
59
  Compare: CompareRenderer,
60
+ Model: ModelRenderer,
60
61
  Map: MapRenderer,
61
62
  Place: null,
62
63
  Sources: SourcesRenderer,
@@ -1,8 +1,9 @@
1
1
  import clsx from 'clsx';
2
2
  import { createContext, useContext, useId, useState, type ReactNode } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { Box } from 'lucide-react';
4
5
  import type { UiProps } from '@adecore/intelligent-ui';
5
- import { Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
6
+ import { ErrorBoundary, Icon, Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
6
7
  import { formatNumber } from '@adecore/ui/format';
7
8
  import { useChatHost } from '../../../../host';
8
9
  import { CodeBlock } from '../../CodeBlock';
@@ -187,6 +188,55 @@ function CompareChip({ side, label }: { side: 'left' | 'right'; label: string })
187
188
  );
188
189
  }
189
190
 
191
+ /*
192
+ * The host draws the model, since the chat knows no 3D format. The frame has a fixed height in the reply column,
193
+ * so a view that loads late never moves the thread. Without a view of the host, or without the bytes, the model
194
+ * stays a quiet row that names it.
195
+ */
196
+ export function ModelRenderer({ node, children, context }: UiRendererProps<UiProps<'Model'>>) {
197
+ const { t } = useTranslation('agent-chat');
198
+ const host = useChatHost();
199
+ const { attachment, alt } = node.props;
200
+ const source = host.attachments.useUrl(context.scopeId, context.chatId, attachment);
201
+ const caption = uiNodeLabel(node);
202
+ const label = alt ?? (caption === '' ? t('blocks.model.label') : caption);
203
+ const View = host.ModelView;
204
+ let body: ReactNode;
205
+ if (View !== undefined && source.url !== null) {
206
+ body = (
207
+ <div role="group" aria-label={label} className="relative h-80 w-full overflow-hidden rounded-lg border border-border bg-surface-sunken">
208
+ <ErrorBoundary label={t('blocks.model.crashed')} resetKeys={[attachment, source.url]} compact>
209
+ <View
210
+ scopeId={context.scopeId}
211
+ chatId={context.chatId}
212
+ attachmentId={attachment}
213
+ url={source.url}
214
+ label={label}
215
+ alt={alt}
216
+ caption={caption}
217
+ />
218
+ </ErrorBoundary>
219
+ </div>
220
+ );
221
+ } else if (View !== undefined && source.failure === null) {
222
+ body = <div aria-busy="true" aria-label={label} className="h-80 w-full rounded-lg border border-border bg-surface-sunken" />;
223
+ } else {
224
+ body = (
225
+ <div className="flex w-full min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs">
226
+ <Icon icon={Box} size={14} className="shrink-0 text-text-muted" />
227
+ <span className="min-w-0 truncate text-text">{alt ?? attachment}</span>
228
+ <span className="ml-auto shrink-0 text-text-faint">{source.failure === null ? t('blocks.model.label') : t('blocks.model.unavailable')}</span>
229
+ </div>
230
+ );
231
+ }
232
+ return (
233
+ <figure className="flex flex-col items-start gap-1.5 px-2">
234
+ {body}
235
+ {caption !== '' && <figcaption className="text-xs text-text-muted">{children}</figcaption>}
236
+ </figure>
237
+ );
238
+ }
239
+
190
240
  export function SourcesRenderer({ node, children }: UiRendererProps<UiProps<'Sources'>>) {
191
241
  const { t } = useTranslation('agent-chat');
192
242
  const headingId = useId();
@@ -381,7 +381,7 @@ export function ThinkingRow({ chatId, item: derived }: { chatId: string; item: C
381
381
  )}
382
382
  </button>
383
383
  {shown && text !== '' && (
384
- <div data-find-field="text" className="mt-1 border-l border-border pl-2.5 select-text">
384
+ <div data-find-field="text" className="mt-1 ml-1.75 border-l border-border pl-4 select-text">
385
385
  {reveal.active ? (
386
386
  <div className="text-sm whitespace-pre-wrap text-text-faint">
387
387
  <FadingWords text={reveal.text} />
@@ -535,7 +535,7 @@ export function ApprovalHistoryRow({ item }: { item: ChatApprovalItem }) {
535
535
  <span className={clsx(ROW_GUTTER, item.decision === 'deny' && 'text-status-error')}>{DECISION_ICON[item.decision]}</span>
536
536
  <span className={clsx(item.decision === 'deny' && 'text-status-error')}>{t(`rows.approval.${item.decision}`)}</span>
537
537
  <span className="text-text-muted">{item.toolName}</span>
538
- <span className="min-w-0 truncate font-mono">
538
+ <span className="min-w-0 truncate font-mono text-code">
539
539
  {toolSummaryIsPath(item.toolName, item.input) ? (
540
540
  <MiddleTruncate text={toolSummary(item.toolName, item.input)} />
541
541
  ) : (
@@ -11,6 +11,7 @@ import { useChatScope } from '../../../scope';
11
11
  import { useChats } from '../../../state/chats';
12
12
  import { useProviders } from '../../../state/providers';
13
13
  import { taskRowStateOf, type TaskRowState } from '../../subagent-list';
14
+ import { ROW_GUTTER } from '../icons';
14
15
 
15
16
  const DOT: Record<TaskRowState, string> = {
16
17
  running: 'bg-status-running',
@@ -40,7 +41,7 @@ function StateLine({ state, task }: { state: TaskRowState; task: SubagentTask |
40
41
  const until = state === 'paused' ? task?.paused?.until : undefined;
41
42
  const now = useNow(60_000, until === undefined);
42
43
  return (
43
- <span className={clsx('truncate text-xs', STATE_TONE[state])}>
44
+ <span className={clsx('truncate text-2xs', STATE_TONE[state])}>
44
45
  {until === undefined ? t(`rows.task.${state}`) : t('rows.task.pausedUntil', { time: formatMoment(until, now) })}
45
46
  </span>
46
47
  );
@@ -70,10 +71,10 @@ export function TaskRow({ item, task, open, onPress }: { item: ChatSubagentItem;
70
71
  const finishedAt = task === null ? item.finishedAt : (task.settledAt ?? item.finishedAt);
71
72
  const settled = state === 'done' || state === 'failed' || state === 'cancelled';
72
73
  return (
73
- <button className="-mx-1 mb-0.5 flex w-[calc(100%+8px)] items-center gap-2.5 rounded-md px-1 py-1 text-left hover:bg-surface-hover" onClick={onPress}>
74
- <span className="relative grid size-7 shrink-0 place-items-center rounded-full bg-surface-raised text-text">
74
+ <button className="-mx-1 mb-0.5 flex w-[calc(100%+8px)] items-start gap-2 rounded-md px-1 py-1 text-left hover:bg-surface-hover" onClick={onPress}>
75
+ <span className={clsx(ROW_GUTTER, 'relative text-text')}>
75
76
  {provider === undefined ? <Icon icon={Bot} size={14} /> : <AgentIcon kind={provider} size={14} />}
76
- <span className={clsx('absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-surface', DOT[state])} />
77
+ <span className={clsx('absolute -right-0.75 bottom-0 size-2 rounded-full ring-2 ring-surface', DOT[state])} />
77
78
  </span>
78
79
  <span className="flex min-w-0 grow flex-col">
79
80
  <span data-find-field="summary" className="truncate text-xs font-medium text-text">
@@ -81,11 +82,13 @@ export function TaskRow({ item, task, open, onPress }: { item: ChatSubagentItem;
81
82
  </span>
82
83
  <StateLine state={state} task={task} />
83
84
  </span>
84
- {(state === 'running' || state === 'waiting') && startedAt > 0 && <Elapsed startedAt={startedAt} />}
85
- {settled && startedAt > 0 && finishedAt !== null && finishedAt >= startedAt && (
86
- <span className="shrink-0 text-xs text-text-faint tabular-nums">{formatElapsedShort(finishedAt - startedAt)}</span>
87
- )}
88
- <Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90')} />
85
+ <span className="flex h-5 shrink-0 items-center gap-2">
86
+ {(state === 'running' || state === 'waiting') && startedAt > 0 && <Elapsed startedAt={startedAt} />}
87
+ {settled && startedAt > 0 && finishedAt !== null && finishedAt >= startedAt && (
88
+ <span className="shrink-0 text-xs text-text-faint tabular-nums">{formatElapsedShort(finishedAt - startedAt)}</span>
89
+ )}
90
+ <Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90')} />
91
+ </span>
89
92
  </button>
90
93
  );
91
94
  }
@@ -162,7 +162,7 @@ export function ToggleLine({
162
162
  {look !== undefined ? (
163
163
  <>
164
164
  {look.run.detail !== '' && (
165
- <span data-find-field="summary" className="min-w-0 truncate font-mono text-text-faint">
165
+ <span data-find-field="summary" className="min-w-0 truncate font-mono text-code text-text-faint">
166
166
  <LookDetail run={look.run} chatId={look.chatId} />
167
167
  </span>
168
168
  )}
@@ -170,7 +170,7 @@ export function ToggleLine({
170
170
  </>
171
171
  ) : (
172
172
  detail && (
173
- <span data-find-field="summary" className="min-w-0 truncate font-mono text-text-faint">
173
+ <span data-find-field="summary" className="min-w-0 truncate font-mono text-code text-text-faint">
174
174
  {command ? <ShellCommand command={detail} /> : path ? <MiddleTruncate text={detail} /> : detail}
175
175
  </span>
176
176
  )
package/src/host.ts CHANGED
@@ -260,6 +260,23 @@ export type UiMapSource =
260
260
  }
261
261
  | { state: 'off'; reason: 'disabled' | 'signed-out' | 'unavailable' };
262
262
 
263
+ /*
264
+ * What the host's view of a Model block gets. It is drawn only once the attachment has a URL, inside a frame
265
+ * of fixed height that the block sizes and captions itself, so the view fills its parent and draws no caption.
266
+ */
267
+ export interface UiModelViewProps {
268
+ scopeId: string;
269
+ chatId: string;
270
+ attachmentId: string;
271
+ /* The attachment's bytes, as `attachments.useUrl` resolved them. */
272
+ url: string;
273
+ /* What the model shows, for a screen reader: the block's alt, its caption, or a word for a 3D model. */
274
+ label: string;
275
+ alt?: string;
276
+ /* The caption as plain text, empty without one. */
277
+ caption: string;
278
+ }
279
+
263
280
  /*
264
281
  * What the app around the chat decides and the chat cannot, handed to the views by a `ChatHostProvider`.
265
282
  * Whatever an app leaves out keeps the default here, which is the chat without that part. One host may
@@ -313,6 +330,8 @@ export interface ChatHost {
313
330
  };
314
331
  /* The image a tool read, drawn under its row; null draws none. */
315
332
  ReadImage: ComponentType<{ path: string }> | null;
333
+ /* The 3D view of a Model block; left out, a model shows as a row with its name and caption. */
334
+ ModelView?: ComponentType<UiModelViewProps>;
316
335
  /* An image a reply's markdown names by a path on the host's machine, absolute or under the chat's cwd; left out, the `<img>` stays as written. */
317
336
  MarkdownImage?: ComponentType<{ path: string; alt: string }>;
318
337
  code: {
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  type ReplyAuthor,
16
16
  type ChatHost,
17
17
  type UiMapSource,
18
+ type UiModelViewProps,
18
19
  type CommandLook,
19
20
  type CommandLookMatch,
20
21
  type CommandName,
@@ -621,6 +621,11 @@ export default {
621
621
  label: 'Image',
622
622
  unavailable: 'The image is not available'
623
623
  },
624
+ model: {
625
+ label: '3D model',
626
+ unavailable: 'The model is not available',
627
+ crashed: 'This model failed to render'
628
+ },
624
629
  sources: 'Sources',
625
630
  citation: 'Source {{number}}: {{title}}',
626
631
  outsideProject: 'Outside the project, shown as text',
@@ -621,6 +621,11 @@ export default {
621
621
  label: 'Afbeelding',
622
622
  unavailable: 'De afbeelding is niet beschikbaar'
623
623
  },
624
+ model: {
625
+ label: '3D-model',
626
+ unavailable: 'Het model is niet beschikbaar',
627
+ crashed: 'Dit model kon niet worden getekend'
628
+ },
624
629
  sources: 'Bronnen',
625
630
  citation: 'Bron {{number}}: {{title}}',
626
631
  outsideProject: 'Buiten het project, als tekst getoond',