@adecore/agents-react 0.27.5 → 0.29.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.
Files changed (43) hide show
  1. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +19 -9
  2. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +4 -2
  3. package/dist/chat/ui/intelligent-ui/UiReply.js +3 -3
  4. package/dist/chat/ui/intelligent-ui/registry.js +2 -1
  5. package/dist/chat/ui/intelligent-ui/renderers/chart.js +2 -2
  6. package/dist/chat/ui/intelligent-ui/renderers/choices.js +13 -8
  7. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +1 -0
  8. package/dist/chat/ui/intelligent-ui/renderers/content.js +38 -11
  9. package/dist/chat/ui/intelligent-ui/renderers/data.js +19 -6
  10. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +10 -9
  11. package/dist/chat/ui/intelligent-ui/renderers/links.js +5 -3
  12. package/dist/chat/ui/intelligent-ui/renderers/map.js +7 -7
  13. package/dist/chat/ui/intelligent-ui/renderers/structure.js +5 -5
  14. package/dist/chat/ui/intelligent-ui/renderers/text.js +25 -14
  15. package/dist/chat/ui/intelligent-ui/tones.d.ts +2 -0
  16. package/dist/chat/ui/intelligent-ui/tones.js +16 -0
  17. package/dist/host.d.ts +10 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/locales/en/agent-chat.d.ts +5 -0
  20. package/dist/locales/en/agent-chat.js +5 -0
  21. package/dist/locales/nl/agent-chat.d.ts +5 -0
  22. package/dist/locales/nl/agent-chat.js +5 -0
  23. package/dist/theme.css +38 -0
  24. package/package.json +8 -8
  25. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +26 -13
  26. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +7 -4
  27. package/src/chat/ui/intelligent-ui/UiReply.tsx +3 -3
  28. package/src/chat/ui/intelligent-ui/registry.ts +2 -1
  29. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +2 -2
  30. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +33 -19
  31. package/src/chat/ui/intelligent-ui/renderers/content.tsx +82 -19
  32. package/src/chat/ui/intelligent-ui/renderers/data.tsx +61 -19
  33. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +21 -14
  34. package/src/chat/ui/intelligent-ui/renderers/links.tsx +7 -4
  35. package/src/chat/ui/intelligent-ui/renderers/map.tsx +15 -12
  36. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +6 -6
  37. package/src/chat/ui/intelligent-ui/renderers/text.tsx +63 -42
  38. package/src/chat/ui/intelligent-ui/tones.ts +18 -0
  39. package/src/host.ts +19 -0
  40. package/src/index.ts +1 -0
  41. package/src/locales/en/agent-chat.ts +5 -0
  42. package/src/locales/nl/agent-chat.ts +5 -0
  43. package/src/theme.css +38 -0
@@ -34,8 +34,8 @@ function NumberChip({ number, selected }: { number: number; selected: boolean })
34
34
  <span
35
35
  aria-hidden
36
36
  className={clsx(
37
- 'mt-px flex size-5 shrink-0 items-center justify-center rounded-full text-xs tabular-nums',
38
- selected ? 'bg-accent text-accent-text' : 'bg-map-marker text-map-marker-text'
37
+ 'mt-px flex size-5 shrink-0 items-center justify-center rounded-md text-2xs font-semibold tabular-nums',
38
+ selected ? 'bg-accent text-accent-text' : 'bg-ui-inset text-text'
39
39
  )}
40
40
  >
41
41
  {number}
@@ -50,7 +50,7 @@ function PlaceRow({ listed, number, selected, onPick }: { listed: ListedPlace; n
50
50
  <>
51
51
  <NumberChip number={number} selected={selected} />
52
52
  <span className="flex min-w-0 grow flex-col gap-0.5">
53
- <span className="text-sm wrap-break-word text-text">{listed.name}</span>
53
+ <span className="text-xs font-medium wrap-break-word text-text">{listed.name}</span>
54
54
  {detail !== undefined && detail !== '' && <span className="text-xs wrap-break-word text-text-muted">{detail}</span>}
55
55
  {address !== undefined && address !== '' && <span className="text-xs wrap-break-word text-text-faint">{address}</span>}
56
56
  {listed.point === undefined && <span className="text-xs text-text-faint">{t('blocks.map.lookingUp')}</span>}
@@ -58,13 +58,18 @@ function PlaceRow({ listed, number, selected, onPick }: { listed: ListedPlace; n
58
58
  </span>
59
59
  </>
60
60
  );
61
- const row = 'flex w-full min-h-8 items-start gap-2 rounded-md px-2 py-1.5 text-left';
61
+ const row = 'flex h-full w-full min-h-12 items-start gap-2.5 rounded-xl bg-ui-tile px-3 py-2 text-left';
62
62
  return (
63
63
  <li>
64
64
  {onPick === null ? (
65
- <div className={clsx(row, selected && 'bg-surface-hover')}>{body}</div>
65
+ <div className={clsx(row, selected && 'ring-2 ring-accent ring-inset')}>{body}</div>
66
66
  ) : (
67
- <button type="button" aria-pressed={selected} className={clsx(row, 'hover:bg-surface-hover', selected && 'bg-surface-hover')} onClick={onPick}>
67
+ <button
68
+ type="button"
69
+ aria-pressed={selected}
70
+ className={clsx(row, 'hover:bg-surface-active', selected && 'ring-2 ring-accent ring-inset')}
71
+ onClick={onPick}
72
+ >
68
73
  {body}
69
74
  </button>
70
75
  )}
@@ -150,7 +155,7 @@ function MapFrame({ markers, source, selected, onPick }: FrameProps) {
150
155
  }
151
156
  return (
152
157
  <>
153
- <div ref={setElement} className="relative h-50 overflow-hidden rounded-lg border border-border bg-surface-sunken @min-[420px]:h-60">
158
+ <div ref={setElement} className="relative h-50 overflow-hidden rounded-xl bg-ui-tile @min-[420px]:h-60">
154
159
  {body}
155
160
  {waiting && (
156
161
  <div className="pointer-events-none absolute inset-0 grid place-items-center">
@@ -200,9 +205,9 @@ export function MapRenderer({ node, children, context }: UiRendererProps<UiProps
200
205
  <MapFrame markers={markers} source={null} selected={selectedId} onPick={onPick} />
201
206
  );
202
207
  return (
203
- <div className={clsx('@container flex flex-col gap-1.5 px-2', !node.complete && PENDING)}>
208
+ <div className={clsx('@container flex flex-col gap-1.5', !node.complete && PENDING)}>
204
209
  {frame}
205
- <ol aria-label={t('blocks.map.label', { count: places.length })} className="flex flex-col">
210
+ <ol aria-label={t('blocks.map.label', { count: places.length })} className="grid gap-1.5 @min-[420px]:grid-cols-2">
206
211
  {places.map((listed, index) => {
207
212
  const { value } = listed.node.props;
208
213
  return (
@@ -216,9 +221,7 @@ export function MapRenderer({ node, children, context }: UiRendererProps<UiProps
216
221
  );
217
222
  })}
218
223
  </ol>
219
- {node.omitted !== undefined && node.omitted > 0 && (
220
- <p className="px-2 text-xs text-text-faint">{t('blocks.map.omitted', { count: node.omitted })}</p>
221
- )}
224
+ {node.omitted !== undefined && node.omitted > 0 && <p className="text-2xs text-text-faint">{t('blocks.map.omitted', { count: node.omitted })}</p>}
222
225
  {/* A place the catalog refused draws as its text, which is all its node renders. */}
223
226
  <div className="chat-ui-nodes flex flex-col gap-1.5 empty:hidden">{children}</div>
224
227
  </div>
@@ -22,7 +22,7 @@ export function TabsRenderer({ node, children, context }: UiRendererProps<UiProp
22
22
  const value = picked !== null && tabs.some((tab) => tab.id === picked) ? picked : (tabs[0]?.id ?? null);
23
23
  return (
24
24
  <Tabs.Root value={value} onValueChange={(next) => setPicked(String(next))} className="flex flex-col gap-3">
25
- <Tabs.List className="px-2">
25
+ <Tabs.List variant="pill" overflow="scroll" className="w-fit max-w-full rounded-xl bg-ui-tile p-0.75">
26
26
  {tabs.map((tab) => (
27
27
  <Tabs.Tab key={tab.id} value={tab.id} data-ui-reveal={tab.id}>
28
28
  {tab.props.title}
@@ -46,24 +46,24 @@ export function SectionsRenderer({ node, children, context }: UiRendererProps<Ui
46
46
  const [touched, setTouched] = useBlockLocal(context, node.id, () => false);
47
47
  return (
48
48
  <SectionsContext value={{ touched, touch: () => setTouched(true) }}>
49
- <div className="chat-ui-nodes flex flex-col divide-y divide-border-soft">{children}</div>
49
+ <div className="chat-ui-nodes flex flex-col gap-1.5">{children}</div>
50
50
  </SectionsContext>
51
51
  );
52
52
  }
53
53
 
54
- /* Arrives open until the person folds a section of the set themselves; the content starts under the title. */
54
+ /* Arrives open until the person folds a section of the set themselves; the content keeps one margin on both sides. */
55
55
  export function SectionRenderer({ node, children, context }: UiRendererProps<UiProps<'Section'>>) {
56
56
  const fold = useContext(SectionsContext);
57
57
  const [open, setOpen] = useBlockLocal(context, node.id, () => !fold.touched);
58
58
  const contentId = useId();
59
59
  return (
60
- <section>
60
+ <section className="rounded-xl bg-ui-tile">
61
61
  <button
62
62
  type="button"
63
63
  data-ui-reveal={node.id}
64
64
  aria-expanded={open}
65
65
  aria-controls={contentId}
66
- className="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm font-medium text-text hover:bg-surface-hover"
66
+ className="flex min-h-10 w-full items-center gap-2 rounded-xl px-3 text-left text-xs font-medium text-text hover:bg-text/4"
67
67
  onClick={() => {
68
68
  fold.touch();
69
69
  setOpen(!open);
@@ -76,7 +76,7 @@ export function SectionRenderer({ node, children, context }: UiRendererProps<UiP
76
76
  />
77
77
  <span className="min-w-0 truncate">{node.props.title}</span>
78
78
  </button>
79
- <div id={contentId} hidden={!open} className="chat-ui-nodes flex flex-col gap-3 pt-1 pr-2 pb-2 pl-7.5">
79
+ <div id={contentId} hidden={!open} className="chat-ui-section-body chat-ui-nodes flex flex-col gap-3 px-3 pb-3">
80
80
  {children}
81
81
  </div>
82
82
  </section>
@@ -7,7 +7,7 @@ import { Icon, Meter, Pill, Spinner } from '@adecore/ui';
7
7
  import { formatNumber, formatPercent } from '@adecore/ui/format';
8
8
  import { UiHeadContext, type UiRenderContext, type UiRendererProps } from '../render-context';
9
9
  import { uiEventTime, uiNodeLabel } from '../node-text';
10
- import { UI_TONE_ICONS, UI_TONE_PILL, UI_TONE_SURFACE, UI_TONE_TEXT } from '../tones';
10
+ import { UI_STEP_PILL, UI_TONE_FILL, UI_TONE_ICONS, UI_TONE_PILL, UI_TONE_SURFACE, UI_TONE_TEXT } from '../tones';
11
11
 
12
12
  type StepState = UiProps<'Step'>['state'];
13
13
 
@@ -26,43 +26,56 @@ const STEP_TONES: Readonly<Record<StepState, string>> = {
26
26
  skipped: 'text-text-faint'
27
27
  };
28
28
 
29
+ /* A detail longer than this reads as a sentence, which a tag cannot wrap. */
30
+ const DETAIL_AS_TAG = 24;
31
+
29
32
  /* Whether the block still changes: while it streams, or while it reads live data. Only then does anything spin. */
30
33
  function isLive(context: UiRenderContext): boolean {
31
34
  return context.phase === 'streaming' || (context.live !== undefined && context.live !== null && context.live.state !== 'refused');
32
35
  }
33
36
 
34
- /* A badge longer than this is a sentence, which a tag cannot wrap; it stands under the title as text instead. */
35
- const BADGE_AS_TAG = 32;
37
+ /* A Summary and a Callout share one row: the icon in a square, and beside it text that centers on the square while it is shorter and starts at its top once it is taller. */
38
+ const ICON_ROW = 'flex items-start gap-3 rounded-xl p-3';
39
+ const ICON_SQUARE = 'grid size-8.5 shrink-0 place-items-center rounded-lg';
40
+ const ICON_TEXT = 'flex min-h-8.5 min-w-0 grow flex-col justify-center gap-0.5';
36
41
 
37
- /* The head of the block when it is the first node, a subheading without an icon further down. */
42
+ /*
43
+ * A tile in its tone; the badge stands under the title in the tone's color. The head of the block
44
+ * carries its icon in a square of its own, a Summary further down is a subheading without one. The
45
+ * icon is the ground's ink in the dark theme, where its tones are too light for white.
46
+ */
38
47
  export function SummaryRenderer({ node, children }: UiRendererProps<UiProps<'Summary'>>) {
39
48
  const headId = useContext(UiHeadContext);
40
49
  const head = headId === undefined || headId === node.id;
41
50
  const { tone, badge } = node.props;
42
- const tag = badge !== undefined && badge !== '' && badge.length <= BADGE_AS_TAG;
51
+ const shown = tone ?? 'neutral';
43
52
  return (
44
- <div className="flex items-start gap-2 px-2">
45
- {head && tone !== undefined && <Icon icon={UI_TONE_ICONS[tone]} size={16} className={clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone])} />}
46
- <div className="flex min-w-0 grow flex-wrap items-start gap-x-2 gap-y-1">
47
- <div className="max-w-full min-w-0 grow basis-48 text-sm font-semibold text-balance wrap-break-word text-text">{children}</div>
48
- {tag && (
49
- <Pill shape="tag" tone={tone === undefined ? 'muted' : UI_TONE_PILL[tone]}>
50
- {badge}
51
- </Pill>
53
+ <div className={clsx(ICON_ROW, tone === undefined ? 'bg-ui-tile' : UI_TONE_SURFACE[tone])}>
54
+ {head && (
55
+ <span aria-hidden className={clsx(ICON_SQUARE, 'text-accent-text dark:text-surface', UI_TONE_FILL[shown])}>
56
+ <Icon icon={UI_TONE_ICONS[shown]} size={16} />
57
+ </span>
58
+ )}
59
+ <div className={ICON_TEXT}>
60
+ <div className="text-base font-semibold text-balance wrap-break-word text-text">{children}</div>
61
+ {badge !== undefined && badge !== '' && (
62
+ <p className={clsx('text-2xs font-medium wrap-anywhere', tone === undefined ? 'text-text-muted' : UI_TONE_TEXT[tone])}>{badge}</p>
52
63
  )}
53
- {!tag && badge !== undefined && badge !== '' && <p className="w-full text-xs wrap-anywhere text-text-muted">{badge}</p>}
54
64
  </div>
55
65
  </div>
56
66
  );
57
67
  }
58
68
 
69
+ /* A quiet tile whatever its tone: only the square of its icon wears the tone. */
59
70
  export function CalloutRenderer({ node, children }: UiRendererProps<UiProps<'Callout'>>) {
60
71
  const { tone, title } = node.props;
61
72
  return (
62
- <div className={clsx('flex gap-2 rounded-md p-2 text-sm', UI_TONE_SURFACE[tone])}>
63
- <Icon icon={UI_TONE_ICONS[tone]} size={16} className={clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone])} />
64
- <div className="min-w-0 grow">
65
- {title !== undefined && title !== '' && <div className="font-medium text-text">{title}</div>}
73
+ <div className={clsx('chat-ui-flat bg-ui-tile text-xs', ICON_ROW)}>
74
+ <span aria-hidden className={clsx(ICON_SQUARE, UI_TONE_SURFACE[tone], UI_TONE_TEXT[tone])}>
75
+ <Icon icon={UI_TONE_ICONS[tone]} size={16} />
76
+ </span>
77
+ <div className={ICON_TEXT}>
78
+ {title !== undefined && title !== '' && <div className="font-semibold text-text">{title}</div>}
66
79
  <div className="text-text-muted">{children}</div>
67
80
  </div>
68
81
  </div>
@@ -87,7 +100,7 @@ export function ProgressRenderer({ node, children, context }: UiRendererProps<Ui
87
100
  value === undefined ? null : limit === 100 ? formatPercent(value) : t('blocks.progress.of', { value: formatNumber(value), max: formatNumber(limit) });
88
101
  const label = uiNodeLabel(node);
89
102
  return (
90
- <div className="flex flex-col gap-1 px-2">
103
+ <div className="chat-ui-flat flex flex-col gap-2 rounded-xl bg-ui-tile p-3">
91
104
  <div className="flex items-baseline justify-between gap-3 text-xs">
92
105
  <span className="min-w-0 text-text">{children}</span>
93
106
  {reading !== null ? (
@@ -102,17 +115,17 @@ export function ProgressRenderer({ node, children, context }: UiRendererProps<Ui
102
115
  }
103
116
 
104
117
  export function StepsRenderer({ children }: UiRendererProps<UiProps<'Steps'>>) {
105
- return <ol className="chat-ui-nodes flex flex-col px-2">{children}</ol>;
118
+ return <ol className="chat-ui-nodes flex flex-col gap-1.5">{children}</ol>;
106
119
  }
107
120
 
108
- /* A running step spins only while the block is live; in a quiet block it is a still dot. */
121
+ /* A tile per step with its state in words at the end. A running step spins only while the block is live; in a quiet block it is a still dot. */
109
122
  export function StepRenderer({ node, children, context }: UiRendererProps<UiProps<'Step'>>) {
110
123
  const { t } = useTranslation('agent-chat');
111
124
  const { state, detail } = node.props;
112
125
  const quiet = state === 'pending' || state === 'skipped';
113
126
  return (
114
- <li className="flex min-h-7 items-center gap-2 text-sm">
115
- <span className={clsx('grid size-4 shrink-0 place-items-center', STEP_TONES[state])}>
127
+ <li className="flex min-h-10 items-start gap-3 rounded-xl bg-ui-tile px-3 py-2.5 text-xs">
128
+ <span className={clsx('mt-0.5 grid size-4 shrink-0 place-items-center', STEP_TONES[state])}>
116
129
  {state === 'running' ? (
117
130
  isLive(context) ? (
118
131
  <Spinner size={14} />
@@ -123,21 +136,23 @@ export function StepRenderer({ node, children, context }: UiRendererProps<UiProp
123
136
  <Icon icon={STEP_ICONS[state]} size={14} />
124
137
  )}
125
138
  </span>
126
- <span className="sr-only">{t(`blocks.step.${state}`)}</span>
127
139
  {/* A short detail stays beside the title; one that does not fit beside it takes the line under it. */}
128
- <span className="flex min-w-0 grow flex-wrap items-baseline justify-between gap-x-2">
129
- <span className={clsx('max-w-full min-w-0 grow basis-48 wrap-break-word', quiet ? 'text-text-faint' : 'text-text')}>{children}</span>
130
- {detail !== undefined && detail !== '' && <span className="max-w-full text-xs wrap-anywhere text-text-faint tabular-nums">{detail}</span>}
140
+ <span className="flex min-w-0 grow flex-wrap items-baseline gap-x-2">
141
+ <span className={clsx('max-w-full min-w-0 font-medium wrap-break-word', quiet ? 'text-text-muted' : 'text-text')}>{children}</span>
142
+ {detail !== undefined && detail !== '' && <span className="max-w-full text-2xs wrap-anywhere text-text-muted tabular-nums">{detail}</span>}
131
143
  </span>
144
+ <Pill shape="tag" size="sm" tone={UI_STEP_PILL[state]} className="shrink-0">
145
+ {t(`blocks.step.${state}`)}
146
+ </Pill>
132
147
  </li>
133
148
  );
134
149
  }
135
150
 
136
151
  export function TimelineRenderer({ children }: UiRendererProps<UiProps<'Timeline'>>) {
137
- return <ol className="chat-ui-nodes flex flex-col px-2">{children}</ol>;
152
+ return <ol className="chat-ui-nodes chat-ui-flat flex flex-col rounded-xl bg-ui-tile px-3 py-1.5">{children}</ol>;
138
153
  }
139
154
 
140
- /* Each row draws its own stretch of the line, down to the next dot, so a row that failed to draw leaves the others whole. */
155
+ /* A row of the tile: the time, a dot in the tone, the title, and a short detail as a tag at the end or a longer one under the title. */
141
156
  export function EventRenderer({ node, children }: UiRendererProps<UiProps<'Event'>>) {
142
157
  const { t } = useTranslation('agent-chat');
143
158
  const { at, detail } = node.props;
@@ -145,22 +160,28 @@ export function EventRenderer({ node, children }: UiRendererProps<UiProps<'Event
145
160
  const time = uiEventTime(at);
146
161
  // A neutral dot says nothing the title does not, and a detail is part of what the row says.
147
162
  const label = [time, uiNodeLabel(node), tone === 'neutral' ? '' : t(`blocks.event.tone.${tone}`), detail ?? ''].filter((part) => part !== '').join(', ');
163
+ const described = detail !== undefined && detail !== '';
164
+ const tagged = described && detail.length <= DETAIL_AS_TAG;
148
165
  return (
149
- <li aria-label={label} className="group/event relative flex min-h-7 gap-3 pb-3 last:pb-0">
150
- <span aria-hidden className={clsx('mt-1 size-2 shrink-0 rounded-full bg-current', UI_TONE_TEXT[tone])} />
151
- <span aria-hidden className="absolute top-3.5 bottom-0 left-0.75 w-px bg-border group-last/event:hidden" />
166
+ <li aria-label={label} className="flex min-h-9 items-start gap-3 py-2 text-xs">
167
+ {/* A time nobody could read stays as written and is no `dateTime` for a machine to read. */}
168
+ {parseUiEventTime(at) === null ? (
169
+ <span className="w-24 shrink-0 text-2xs leading-5 wrap-anywhere text-text-muted tabular-nums">{time}</span>
170
+ ) : (
171
+ <time dateTime={at} className="w-24 shrink-0 text-2xs leading-5 text-text-muted tabular-nums">
172
+ {time}
173
+ </time>
174
+ )}
175
+ <span aria-hidden className={clsx('mt-1.5 size-2 shrink-0 rounded-full bg-current', tone === 'neutral' ? 'text-text-faint' : UI_TONE_TEXT[tone])} />
152
176
  <div className="flex min-w-0 grow flex-col gap-0.5">
153
- {/* A time nobody could read stays as written and is no `dateTime` for a machine to read. */}
154
- {parseUiEventTime(at) === null ? (
155
- <span className="text-xs wrap-anywhere text-text-muted tabular-nums">{time}</span>
156
- ) : (
157
- <time dateTime={at} className="text-xs text-text-muted tabular-nums">
158
- {time}
159
- </time>
160
- )}
161
- <div className="text-sm wrap-break-word text-text">{children}</div>
162
- {detail !== undefined && detail !== '' && <div className="text-xs wrap-anywhere text-text-muted">{detail}</div>}
177
+ <div className="wrap-break-word text-text">{children}</div>
178
+ {described && !tagged && <div className="text-2xs wrap-anywhere text-text-muted">{detail}</div>}
163
179
  </div>
180
+ {tagged && (
181
+ <Pill shape="tag" size="sm" tone={UI_TONE_PILL[tone]} className="shrink-0">
182
+ {detail}
183
+ </Pill>
184
+ )}
164
185
  </li>
165
186
  );
166
187
  }
@@ -35,3 +35,21 @@ export const UI_TONE_PILL: Readonly<Record<UiTone, NonNullable<PillProps['tone']
35
35
  warning: 'needsYou',
36
36
  danger: 'error'
37
37
  };
38
+
39
+ /* The tone at full strength, for the square that carries the icon of a Summary. */
40
+ export const UI_TONE_FILL: Readonly<Record<UiTone, string>> = {
41
+ neutral: 'bg-text-muted',
42
+ info: 'bg-status-running',
43
+ success: 'bg-positive',
44
+ warning: 'bg-status-needs-you',
45
+ danger: 'bg-status-error'
46
+ };
47
+
48
+ /* The tag at the end of a step, in words, so a state never rests on its icon alone. */
49
+ export const UI_STEP_PILL: Readonly<Record<'done' | 'running' | 'pending' | 'failed' | 'skipped', NonNullable<PillProps['tone']>>> = {
50
+ done: 'idle',
51
+ running: 'accent',
52
+ pending: 'muted',
53
+ failed: 'error',
54
+ skipped: 'muted'
55
+ };
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',
package/src/theme.css CHANGED
@@ -30,6 +30,12 @@
30
30
  /* The two grounds of the map a page draws without tiles. */
31
31
  --map-land: #f2f1ed;
32
32
  --map-water: #d3dde5;
33
+ /* The parts of an intelligent UI block, from the tokens an app's palette sets: a tile on the thread's
34
+ ground, a card inset in a tile, and the hatch of its band. The tile mixes the ink in so lightly that
35
+ muted text keeps 4.5 to 1 on it; the design's #f0f0f3 did not. */
36
+ --chat-ui-tile: color-mix(in srgb, var(--text) 4.5%, var(--surface));
37
+ --chat-ui-inset: var(--surface);
38
+ --chat-ui-hatch: var(--separator);
33
39
  }
34
40
 
35
41
  [data-theme="dark"] {
@@ -51,6 +57,9 @@
51
57
  --chart-6: #008300;
52
58
  --map-land: #2c3135;
53
59
  --map-water: #1a2026;
60
+ --chat-ui-tile: color-mix(in srgb, var(--text) 3%, var(--surface));
61
+ --chat-ui-inset: var(--surface);
62
+ --chat-ui-hatch: var(--separator);
54
63
  }
55
64
 
56
65
  /* Output and diffs take the terminal's colors when the app loads `@adecore/terminal/terminal.css`,
@@ -82,6 +91,8 @@
82
91
  --color-chat-added: var(--chat-added);
83
92
  --color-chat-removed: var(--chat-removed);
84
93
  --color-map-marker: var(--map-marker);
94
+ --color-ui-tile: var(--chat-ui-tile);
95
+ --color-ui-inset: var(--chat-ui-inset);
85
96
  --color-map-marker-text: var(--map-marker-text);
86
97
  }
87
98
 
@@ -155,6 +166,24 @@
155
166
  @media (prefers-reduced-motion: reduce) {
156
167
  .chat-ui-changed { animation: none; }
157
168
  }
169
+ /*
170
+ * A block stands in a band across the whole thread, hatched above and below, with its tiles at the
171
+ * reading width. The thread's scroller is the container the band measures, so a block anywhere
172
+ * else keeps the width of its column.
173
+ */
174
+ .chat-scroll { container-type: inline-size; }
175
+ .chat-ui-band { position: relative; padding-block: 14px; }
176
+ .chat-ui-band::before, .chat-ui-band::after {
177
+ content: "";
178
+ position: absolute;
179
+ inset-inline: 0;
180
+ height: 14px;
181
+ border-block: 1px solid var(--border);
182
+ background-image: repeating-linear-gradient(-45deg, var(--chat-ui-hatch) 0 1px, transparent 1px 7px);
183
+ }
184
+ .chat-ui-band::before { top: 0; }
185
+ .chat-ui-band::after { bottom: 0; }
186
+ .chat-scroll .chat-ui-band::before, .chat-scroll .chat-ui-band::after { inset-inline: calc(50% - 50cqw); }
158
187
  /* A source row a citation revealed. The row only takes focus from code, since its own button takes a click. */
159
188
  .chat-ui-source:focus { animation: chat-flash 1600ms ease-out both; }
160
189
  /* A visual still on its way says so only once it takes long enough to be noticed. */
@@ -225,6 +254,15 @@
225
254
 
226
255
  /* A code block is shiki's own `<pre>`, which brings a background of its own to override. */
227
256
  .chat-code { position: relative; min-height: 38px; border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: hidden; }
257
+ /* A code block in an intelligent UI block is a card inset in its tile. */
258
+ .chat-ui-code .chat-code { background: var(--chat-ui-inset); }
259
+ /* Inside a Section the section is the tile. A tile that only frames its parts goes flat, so they keep the
260
+ section's own margin, and a card among them takes the inset ground so it still reads as one. Unlayered, so it outweighs
261
+ the utilities it overrides. */
262
+ .chat-ui-section-body .bg-ui-tile { background-color: var(--chat-ui-inset); }
263
+ .chat-ui-section-body .chat-ui-flat { background-color: transparent; padding: 0; }
264
+ .chat-ui-section-body .chat-ui-flat .chat-ui-flat-inner { padding-inline: 0; }
265
+ .chat-ui-section-body .chat-ui-flat thead { background-color: var(--chat-ui-tile); }
228
266
  .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); overflow: auto; user-select: text; background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
229
267
  .chat-code code { font-family: inherit; }
230
268
  .chat-code-actions { position: absolute; top: 4px; right: 4px; border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); box-shadow: 0 0 0 1px var(--border); }