@adecore/agents-react 0.21.0 → 0.21.2
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/dist/agents/status-look.js +2 -3
- package/dist/chat/chat-client.d.ts +1 -1
- package/dist/chat/chat-client.js +2 -2
- package/dist/chat/drafts.js +6 -12
- package/dist/chat/logic/ansi.js +5 -1
- package/dist/chat/logic/fork.js +8 -9
- package/dist/chat/logic/limit.js +17 -14
- package/dist/chat/logic/resume-compaction.js +2 -11
- package/dist/chat/logic/scrubber.js +9 -18
- package/dist/chat/logic/timeline.js +15 -10
- package/dist/chat/logic/tool-catalog.js +3 -4
- package/dist/chat/mentions.js +5 -6
- package/dist/chat/runtime-modes.js +2 -4
- package/dist/chat/stash.js +1 -3
- package/dist/chat/thumbnails.js +3 -4
- package/dist/chat/timeline-scroll.js +39 -45
- package/dist/chat/ui/BookmarkSubmenu.js +1 -5
- package/dist/chat/ui/CodeBlock.js +2 -3
- package/dist/chat/ui/Composer.js +57 -69
- package/dist/chat/ui/Markdown.js +11 -11
- package/dist/chat/ui/Pickers.js +5 -9
- package/dist/chat/ui/QuoteButton.js +2 -3
- package/dist/chat/ui/RunSettings.js +8 -2
- package/dist/chat/ui/Scrubber.js +2 -3
- package/dist/chat/ui/SubagentControls.js +1 -3
- package/dist/chat/ui/Timeline.js +15 -18
- package/dist/chat/ui/TimelineMenu.js +3 -9
- package/dist/chat/ui/UnifiedDiff.js +3 -5
- package/dist/chat/ui/VisualFrame.js +3 -4
- package/dist/chat/ui/composer/keys.js +19 -11
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +14 -1
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +47 -5
- package/dist/chat/ui/intelligent-ui/UiReply.d.ts +3 -1
- package/dist/chat/ui/intelligent-ui/UiReply.js +34 -30
- package/dist/chat/ui/intelligent-ui/block-local.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/block-local.js +30 -1
- package/dist/chat/ui/intelligent-ui/node-text.d.ts +6 -0
- package/dist/chat/ui/intelligent-ui/node-text.js +54 -0
- package/dist/chat/ui/intelligent-ui/registry.js +2 -1
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +4 -1
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +5 -7
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +6 -2
- package/dist/chat/ui/intelligent-ui/renderers/content.js +6 -13
- package/dist/chat/ui/intelligent-ui/renderers/data.js +8 -15
- package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +16 -4
- package/dist/chat/ui/intelligent-ui/renderers/links.js +2 -1
- package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +4 -4
- package/dist/chat/ui/intelligent-ui/use-ui-links.js +29 -10
- package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +2 -1
- package/dist/chat/ui/intelligent-ui/use-ui-queries.js +34 -21
- package/dist/chat/ui/rows/MessageRows.js +3 -4
- package/dist/chat/ui/rows/WorkRows.d.ts +4 -3
- package/dist/chat/ui/rows/WorkRows.js +19 -14
- package/dist/chat/visual-viewport.d.ts +1 -1
- package/dist/chat/visual-viewport.js +9 -8
- package/dist/locales/en/agent-chat.json +23 -4
- package/dist/locales/nl/agent-chat.json +23 -4
- package/dist/port-transport.js +4 -8
- package/dist/state/chats.js +15 -15
- package/dist/state/usage.js +44 -49
- package/dist/storage.js +7 -7
- package/dist/theme.css +6 -14
- package/dist/usage/LimitsList.js +1 -2
- package/dist/usage/UsageBreakdown.js +1 -3
- package/dist/usage/UsageDialog.js +2 -4
- package/dist/usage/format.js +6 -6
- package/dist/usage/money.js +1 -5
- package/dist/usage/summary.js +2 -4
- package/package.json +24 -29
- package/src/agents/status-look.ts +2 -3
- package/src/chat/chat-client.ts +2 -3
- package/src/chat/drafts.ts +6 -14
- package/src/chat/logic/ansi.ts +5 -1
- package/src/chat/logic/fork.ts +8 -9
- package/src/chat/logic/limit.ts +17 -16
- package/src/chat/logic/resume-compaction.ts +2 -11
- package/src/chat/logic/scrubber.ts +13 -17
- package/src/chat/logic/timeline.ts +22 -11
- package/src/chat/logic/tool-catalog.ts +7 -6
- package/src/chat/mentions.ts +5 -6
- package/src/chat/runtime-modes.ts +2 -4
- package/src/chat/stash.ts +1 -3
- package/src/chat/thumbnails.ts +3 -4
- package/src/chat/timeline-scroll.ts +43 -52
- package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
- package/src/chat/ui/CodeBlock.tsx +2 -3
- package/src/chat/ui/Composer.tsx +66 -86
- package/src/chat/ui/Markdown.tsx +13 -17
- package/src/chat/ui/Pickers.tsx +5 -9
- package/src/chat/ui/QuoteButton.tsx +2 -3
- package/src/chat/ui/RunSettings.tsx +9 -2
- package/src/chat/ui/Scrubber.tsx +2 -3
- package/src/chat/ui/SubagentControls.tsx +1 -3
- package/src/chat/ui/SubagentTimeline.tsx +1 -2
- package/src/chat/ui/Timeline.tsx +18 -20
- package/src/chat/ui/TimelineMenu.tsx +3 -9
- package/src/chat/ui/UnifiedDiff.tsx +3 -5
- package/src/chat/ui/VisualFrame.tsx +3 -4
- package/src/chat/ui/composer/keys.ts +21 -11
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +135 -5
- package/src/chat/ui/intelligent-ui/UiReply.tsx +48 -38
- package/src/chat/ui/intelligent-ui/block-local.ts +36 -1
- package/src/chat/ui/intelligent-ui/node-text.ts +60 -0
- package/src/chat/ui/intelligent-ui/registry.ts +2 -1
- package/src/chat/ui/intelligent-ui/render-context.ts +8 -2
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +5 -7
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +6 -2
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +9 -14
- package/src/chat/ui/intelligent-ui/renderers/data.tsx +13 -19
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +28 -5
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +2 -1
- package/src/chat/ui/intelligent-ui/use-ui-links.ts +31 -11
- package/src/chat/ui/intelligent-ui/use-ui-queries.ts +48 -22
- package/src/chat/ui/rehype-chips.ts +1 -1
- package/src/chat/ui/rehype-fade.ts +1 -1
- package/src/chat/ui/remark-html-as-text.ts +1 -1
- package/src/chat/ui/rows/MessageRows.tsx +4 -5
- package/src/chat/ui/rows/WorkRows.tsx +86 -69
- package/src/chat/visual-viewport.ts +10 -8
- package/src/host.ts +1 -3
- package/src/locales/en/agent-chat.json +23 -4
- package/src/locales/nl/agent-chat.json +23 -4
- package/src/port-transport.ts +4 -8
- package/src/state/chats.ts +16 -15
- package/src/state/usage.ts +49 -60
- package/src/storage.ts +7 -7
- package/src/theme.css +6 -14
- package/src/usage/LimitsList.tsx +2 -4
- package/src/usage/UsageBreakdown.tsx +2 -5
- package/src/usage/UsageDialog.tsx +2 -4
- package/src/usage/format.ts +6 -6
- package/src/usage/money.ts +1 -5
- package/src/usage/summary.ts +2 -4
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +0 -15
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +0 -50
- package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +0 -139
|
@@ -28,10 +28,9 @@ function personActsIn(frame: HTMLIFrameElement): boolean {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/*
|
|
31
|
-
* One visual's page in a frame
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* box keeps one height while the page loads and when it fails, so nothing under it moves.
|
|
31
|
+
* One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
|
|
32
|
+
* the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
|
|
33
|
+
* when the host page is served on the app's own. Its box keeps one height while it loads or fails.
|
|
35
34
|
*/
|
|
36
35
|
export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
|
|
37
36
|
const { t } = useTranslation('agent-chat');
|
|
@@ -60,25 +60,35 @@ export function enterAction(keys: EnterKeys, { inOpenFence, list, column }: Ente
|
|
|
60
60
|
return list.empty ? 'leave-list' : 'continue-list';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/*
|
|
64
|
-
|
|
63
|
+
/* The innermost fenced code block around `pos`, if any. */
|
|
64
|
+
function enclosingFence(tree: Tree, pos: number): SyntaxNode | null {
|
|
65
65
|
for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
|
|
66
66
|
if (node.name === 'FencedCode') {
|
|
67
|
-
return
|
|
67
|
+
return node;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
return
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* A fence nobody has closed yet has its opening mark only. */
|
|
74
|
+
function isOpenFence(fence: SyntaxNode): boolean {
|
|
75
|
+
return fence.getChildren('CodeMark').length < 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
|
|
79
|
+
export function inOpenFence(tree: Tree, pos: number): boolean {
|
|
80
|
+
const fence = enclosingFence(tree, pos);
|
|
81
|
+
return fence !== null && pos > fence.from && isOpenFence(fence);
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/* Whether `pos` sits in the body of a fenced code block, open or closed: below its opening line and before its end. */
|
|
74
85
|
export function inFenceBody(tree: Tree, text: string, pos: number): boolean {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return openingEnd !== -1 && pos > openingEnd && (pos < node.to || node.getChildren('CodeMark').length < 2);
|
|
79
|
-
}
|
|
86
|
+
const fence = enclosingFence(tree, pos);
|
|
87
|
+
if (fence === null) {
|
|
88
|
+
return false;
|
|
80
89
|
}
|
|
81
|
-
|
|
90
|
+
const openingEnd = text.indexOf('\n', fence.from);
|
|
91
|
+
return openingEnd !== -1 && pos > openingEnd && (pos < fence.to || isOpenFence(fence));
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/* The spaces Tab inserts at `column`, up to the next stop of four, the way a code editor lines up. */
|
|
@@ -97,7 +107,7 @@ export function inCode(tree: Tree, text: string, pos: number): boolean {
|
|
|
97
107
|
let blockFrom: number | null = null;
|
|
98
108
|
for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
|
|
99
109
|
if (node.name === 'FencedCode') {
|
|
100
|
-
return pos > node.from && (pos < node.to || node
|
|
110
|
+
return pos > node.from && (pos < node.to || isOpenFence(node));
|
|
101
111
|
}
|
|
102
112
|
if (node.name === 'InlineCode' && pos > node.from && pos < node.to) {
|
|
103
113
|
return true;
|
|
@@ -1,11 +1,141 @@
|
|
|
1
|
-
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import type { UiViewNode } from '@adecore/intelligent-ui';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
|
|
6
|
+
import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
|
|
7
|
+
import { formatAgo, formatMoment } from '@adecore/ui/format';
|
|
8
|
+
import { uiBlockHead, uiReasonText } from './node-text';
|
|
9
|
+
import { UiHeadContext, type UiLiveStatus } from './render-context';
|
|
10
|
+
import { useLater } from './use-later';
|
|
11
|
+
|
|
12
|
+
/* Something the compiler repaired, such as a prop it dropped; never an error to a person. */
|
|
13
|
+
export interface UiBlockFix {
|
|
14
|
+
code: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UiBlockFooterProps {
|
|
19
|
+
live?: UiLiveStatus | null;
|
|
20
|
+
/* The label of the Choice that answered the block, and when, in milliseconds since the epoch. */
|
|
21
|
+
answered?: { label: string; at: number } | null;
|
|
22
|
+
fixes?: readonly UiBlockFix[];
|
|
23
|
+
/* Why the whole block is drawn as its text, such as that it is too large. */
|
|
24
|
+
shownAsText?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const NO_FIXES: readonly UiBlockFix[] = [];
|
|
28
|
+
const READING_SPINNER_MS = 1000;
|
|
29
|
+
|
|
30
|
+
/* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
|
|
31
|
+
function LivePart({ live }: { live: UiLiveStatus }) {
|
|
32
|
+
const { t } = useTranslation('agent-chat');
|
|
33
|
+
const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
|
|
34
|
+
const { readAt } = live;
|
|
35
|
+
const fresh = live.state === 'fresh' || live.state === 'reading';
|
|
36
|
+
const ago = useTickingText(() => {
|
|
37
|
+
if (readAt === null) {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
const time = formatAgo(Date.now() - readAt);
|
|
41
|
+
return fresh ? t('blocks.live.read', { time }) : time;
|
|
42
|
+
});
|
|
43
|
+
const source = live.source ?? live.sources[0] ?? '';
|
|
44
|
+
const icon = slow ? (
|
|
45
|
+
<Spinner size={12} />
|
|
46
|
+
) : live.state === 'failed' ? (
|
|
47
|
+
<Icon icon={TriangleAlert} size={12} className="text-status-needs-you" />
|
|
48
|
+
) : live.state === 'refused' ? (
|
|
49
|
+
<Icon icon={Lock} size={12} />
|
|
50
|
+
) : (
|
|
51
|
+
<Icon icon={Radio} size={12} className="text-status-idle" />
|
|
52
|
+
);
|
|
53
|
+
const words =
|
|
54
|
+
live.state === 'failed'
|
|
55
|
+
? t('blocks.live.failed', { source })
|
|
56
|
+
: live.state === 'refused'
|
|
57
|
+
? t('blocks.live.refused', { source })
|
|
58
|
+
: t('blocks.live.label');
|
|
59
|
+
const reason = live.state === 'failed' || live.state === 'refused' ? uiReasonText(t, live.code, live.reason) : undefined;
|
|
60
|
+
const hint = reason !== undefined ? reason : live.sources.length > 0 ? t('blocks.live.sources', { sources: live.sources.join(', ') }) : null;
|
|
61
|
+
const body = (
|
|
62
|
+
<span tabIndex={hint === null ? undefined : 0} className="flex items-center gap-1.5">
|
|
63
|
+
{icon}
|
|
64
|
+
<span>{words}</span>
|
|
65
|
+
{readAt !== null && live.state !== 'refused' && (
|
|
66
|
+
<>
|
|
67
|
+
<span aria-hidden>·</span>
|
|
68
|
+
<time dateTime={new Date(readAt).toISOString()}>
|
|
69
|
+
<span ref={ago} className="tabular-nums" />
|
|
70
|
+
</time>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
{readAt === null && fresh && (
|
|
74
|
+
<>
|
|
75
|
+
<span aria-hidden>·</span>
|
|
76
|
+
<span>{t('blocks.live.notRead')}</span>
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
return hint === null ? body : <Tooltip label={hint}>{body}</Tooltip>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
* The line under a block, only when it has something to say: live, answered, repaired or drawn as
|
|
86
|
+
* text, in that order. A hairline of the soft border runs the full width of the card over it.
|
|
87
|
+
*/
|
|
88
|
+
function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
|
|
89
|
+
const { t } = useTranslation('agent-chat');
|
|
90
|
+
const [fixesOpen, setFixesOpen] = useState(false);
|
|
91
|
+
const listId = useId();
|
|
92
|
+
if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
<div className="-mx-2.25 mt-2.25 -mb-2.25 flex flex-col gap-1.5 border-t border-border-soft px-4.25 py-2 text-xs text-text-faint">
|
|
97
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
98
|
+
{live !== null && <LivePart live={live} />}
|
|
99
|
+
{answered !== null && (
|
|
100
|
+
<span className="flex items-center gap-1.5">
|
|
101
|
+
<span>{t('blocks.answered', { label: answered.label })}</span>
|
|
102
|
+
<span aria-hidden>·</span>
|
|
103
|
+
<time dateTime={new Date(answered.at).toISOString()}>{formatMoment(answered.at)}</time>
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
{fixes.length > 0 && (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
aria-expanded={fixesOpen}
|
|
110
|
+
aria-controls={listId}
|
|
111
|
+
className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
|
|
112
|
+
onClick={() => setFixesOpen(!fixesOpen)}
|
|
113
|
+
>
|
|
114
|
+
<Icon icon={Wrench} size={12} />
|
|
115
|
+
{t('blocks.fixes', { count: fixes.length })}
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
{shownAsText !== null && (
|
|
119
|
+
<span className="flex items-center gap-1.5">
|
|
120
|
+
<span>{t('blocks.shownAsText')}</span>
|
|
121
|
+
<span aria-hidden>·</span>
|
|
122
|
+
<span>{shownAsText}</span>
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
{fixes.length > 0 && (
|
|
127
|
+
<ul id={listId} hidden={!fixesOpen} className="flex flex-col gap-0.5 text-text-muted">
|
|
128
|
+
{fixes.map((fix, index) => (
|
|
129
|
+
<li key={index} className="flex gap-2">
|
|
130
|
+
<span className="shrink-0 font-mono">{fix.code}</span>
|
|
131
|
+
<span className="min-w-0">{fix.message}</span>
|
|
132
|
+
</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
9
139
|
|
|
10
140
|
export interface UiBlockFrameProps extends UiBlockFooterProps {
|
|
11
141
|
/* The evaluated top-level nodes, read for the Summary that heads the block. */
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
memo,
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
|
+
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
10
11
|
useSyncExternalStore,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
type UiNode,
|
|
24
25
|
type UiViewNode
|
|
25
26
|
} from '@adecore/intelligent-ui';
|
|
26
|
-
import { ErrorBoundary } from '@adecore/ui';
|
|
27
|
+
import { ErrorBoundary, Tooltip } from '@adecore/ui';
|
|
27
28
|
import type { ChatUiAnswer, ChatUiQueryState } from '@adecore/agent-contracts';
|
|
28
29
|
import { chatHost } from '../../../host';
|
|
29
30
|
import { UiReplyNavigationContext } from '../reply-context';
|
|
@@ -31,20 +32,19 @@ import { ReplyMarkdown } from '../Markdown';
|
|
|
31
32
|
import { rehypeFadeWords } from '../rehype-fade';
|
|
32
33
|
import { UI_RENDERERS } from './registry';
|
|
33
34
|
import type { UiRenderContext, UiRenderer } from './render-context';
|
|
35
|
+
import { UiPageMemory } from './block-local';
|
|
34
36
|
import { UiBlockFrame } from './UiBlockFrame';
|
|
35
37
|
import { UiFallbackPart } from './UiFallbackPart';
|
|
36
38
|
import { useUiLinks } from './use-ui-links';
|
|
37
39
|
import { useUiQueries, useUiLiveValues } from './use-ui-queries';
|
|
38
40
|
import { UiSourceCitation } from './renderers/content';
|
|
39
|
-
import { uiChildrenOf, revealUiSource } from './node-text';
|
|
41
|
+
import { uiChildrenOf, revealUiSource, uiLiveChoiceReason, uiUrlDomain } from './node-text';
|
|
40
42
|
import { uiReplyParts } from './reply-parts';
|
|
41
43
|
|
|
42
|
-
const localStates = new
|
|
43
|
-
const LOCAL_STATE_LIMIT = 64;
|
|
44
|
+
const localStates = new UiPageMemory<UiState>(64);
|
|
44
45
|
|
|
45
46
|
function localState(key: string, block: UiBlock, answered?: ChatUiAnswer): UiState {
|
|
46
47
|
let state = localStates.get(key);
|
|
47
|
-
localStates.delete(key);
|
|
48
48
|
if (!state) {
|
|
49
49
|
state = new UiState(block);
|
|
50
50
|
if (answered && answered.revision === block.revision) {
|
|
@@ -52,10 +52,7 @@ function localState(key: string, block: UiBlock, answered?: ChatUiAnswer): UiSta
|
|
|
52
52
|
state.set(name, value);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
localStates.set(key, state);
|
|
57
|
-
if (localStates.size > LOCAL_STATE_LIMIT) {
|
|
58
|
-
localStates.delete(localStates.keys().next().value!);
|
|
55
|
+
localStates.set(key, state);
|
|
59
56
|
}
|
|
60
57
|
return state;
|
|
61
58
|
}
|
|
@@ -67,7 +64,7 @@ const BLOCK = '$block';
|
|
|
67
64
|
* Parents whose text is read as written: code, a short tag, and the labels of controls, whose
|
|
68
65
|
* accessible name and sent text are that same string.
|
|
69
66
|
*/
|
|
70
|
-
const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Column']);
|
|
67
|
+
const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Button', 'Column']);
|
|
71
68
|
|
|
72
69
|
/* Parents that stack their children, where a run of text and inline nodes is one paragraph. */
|
|
73
70
|
const PARAGRAPHS: Readonly<Record<string, string>> = {
|
|
@@ -179,8 +176,6 @@ function sourceTargets(nodes: readonly UiViewNode[]): Map<number, CitationTarget
|
|
|
179
176
|
|
|
180
177
|
const PROSE_PLUGINS = [remarkInlineProse, remarkCitations];
|
|
181
178
|
const PROSE_ELEMENTS = ['p', 'br', 'strong', 'em', 'code', 'a', 'span'];
|
|
182
|
-
// Raw HTML never parses here, so the only spans are the words the fade wraps.
|
|
183
|
-
const FADING_PROSE_ELEMENTS = [...PROSE_ELEMENTS, 'span'];
|
|
184
179
|
const FADE_PLUGINS = [rehypeFadeWords];
|
|
185
180
|
const NO_PLUGINS: typeof FADE_PLUGINS = [];
|
|
186
181
|
|
|
@@ -193,9 +188,11 @@ function ProseLink({ href, children }: { href?: string; children?: ReactNode })
|
|
|
193
188
|
return children;
|
|
194
189
|
}
|
|
195
190
|
return (
|
|
196
|
-
<
|
|
197
|
-
{
|
|
198
|
-
|
|
191
|
+
<Tooltip label={uiUrlDomain(href)}>
|
|
192
|
+
<button type="button" className="cursor-pointer text-accent underline underline-offset-2 select-text" onClick={() => openUrl(href)}>
|
|
193
|
+
{children}
|
|
194
|
+
</button>
|
|
195
|
+
</Tooltip>
|
|
199
196
|
);
|
|
200
197
|
}
|
|
201
198
|
|
|
@@ -227,7 +224,7 @@ const UiProse = memo(function UiProse({ text, fade, openUrl }: { text: string; f
|
|
|
227
224
|
<ReactMarkdown
|
|
228
225
|
remarkPlugins={PROSE_PLUGINS}
|
|
229
226
|
rehypePlugins={fade ? FADE_PLUGINS : NO_PLUGINS}
|
|
230
|
-
allowedElements={
|
|
227
|
+
allowedElements={PROSE_ELEMENTS}
|
|
231
228
|
unwrapDisallowed
|
|
232
229
|
components={PROSE_COMPONENTS}
|
|
233
230
|
>
|
|
@@ -275,6 +272,10 @@ function UiNodes({ nodes, parent, texts, context }: UiNodesProps): ReactNode {
|
|
|
275
272
|
</UiNodeBoundary>
|
|
276
273
|
);
|
|
277
274
|
const paragraph = PARAGRAPHS[parent];
|
|
275
|
+
if (parent === 'Choices') {
|
|
276
|
+
// A primary choice stands first in the order a keyboard and a screen reader meet it too, not only on screen.
|
|
277
|
+
return [...nodes].sort((a, b) => Number(b.props.primary === true) - Number(a.props.primary === true)).map(drawn);
|
|
278
|
+
}
|
|
278
279
|
if (paragraph === undefined) {
|
|
279
280
|
return nodes.map(drawn);
|
|
280
281
|
}
|
|
@@ -364,6 +365,9 @@ function UiNodeBody({
|
|
|
364
365
|
);
|
|
365
366
|
}
|
|
366
367
|
|
|
368
|
+
/* What names a block and whether it still streams; the rest of its render context the block works out itself. */
|
|
369
|
+
type UiBlockIdentity = Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'blockId' | 'phase'>;
|
|
370
|
+
|
|
367
371
|
function UiBlockBody({
|
|
368
372
|
block,
|
|
369
373
|
context,
|
|
@@ -371,7 +375,7 @@ function UiBlockBody({
|
|
|
371
375
|
frozen
|
|
372
376
|
}: {
|
|
373
377
|
block: UiBlock;
|
|
374
|
-
context:
|
|
378
|
+
context: UiBlockIdentity;
|
|
375
379
|
answered?: ChatUiAnswer;
|
|
376
380
|
frozen?: ChatUiQueryState['blocks'][string];
|
|
377
381
|
}): ReactNode {
|
|
@@ -379,7 +383,7 @@ function UiBlockBody({
|
|
|
379
383
|
const key = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id]);
|
|
380
384
|
const [state] = useState(() => localState(key, block, answered));
|
|
381
385
|
state.sync(block);
|
|
382
|
-
useSyncExternalStore(
|
|
386
|
+
const stateRevision = useSyncExternalStore(
|
|
383
387
|
(listener) => state.subscribe(listener),
|
|
384
388
|
() => state.snapshot(),
|
|
385
389
|
() => state.snapshot()
|
|
@@ -399,7 +403,7 @@ function UiBlockBody({
|
|
|
399
403
|
? { choiceId: answered.choiceId, state: answered.queued ? ('queued' as const) : ('sent' as const) }
|
|
400
404
|
: optimistic?.revision === block.revision
|
|
401
405
|
? optimistic
|
|
402
|
-
:
|
|
406
|
+
: null;
|
|
403
407
|
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
|
404
408
|
const navigation = useContext(UiReplyNavigationContext);
|
|
405
409
|
const flash = navigation?.flash;
|
|
@@ -407,7 +411,22 @@ function UiBlockBody({
|
|
|
407
411
|
navigation?.chatId === context.chatId && flash?.itemId === context.itemId && flash.blockId === block.id && flash.revision === block.revision;
|
|
408
412
|
const queries = useUiQueries(block, state, context, element, frozen);
|
|
409
413
|
const links = useUiLinks(block, state, context, queries.reads, frozen);
|
|
410
|
-
|
|
414
|
+
// The state changes in place, so its revision is what tells this evaluation it went stale.
|
|
415
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
416
|
+
const evaluation = useMemo(() => evaluateUiBlock(block, state), [block, state, stateRevision]);
|
|
417
|
+
const texts = useMemo(() => writtenTexts(block.nodes), [block]);
|
|
418
|
+
const sources = useMemo(() => sourceTargets(evaluation.nodes), [evaluation]);
|
|
419
|
+
const citations = useMemo(
|
|
420
|
+
() => ({
|
|
421
|
+
sources,
|
|
422
|
+
reveal: element
|
|
423
|
+
? (source: CitationTarget) => {
|
|
424
|
+
void revealUiSource(element, source.id, source.ancestors);
|
|
425
|
+
}
|
|
426
|
+
: undefined
|
|
427
|
+
}),
|
|
428
|
+
[sources, element]
|
|
429
|
+
);
|
|
411
430
|
const liveValue = useUiLiveValues(block, evaluation.nodes, queries.currentReadings);
|
|
412
431
|
const sendChoice = chatHost().intelligentUi?.sendChoice;
|
|
413
432
|
const rendering: UiRenderContext = {
|
|
@@ -415,8 +434,9 @@ function UiBlockBody({
|
|
|
415
434
|
...links,
|
|
416
435
|
answer,
|
|
417
436
|
failedChoiceId,
|
|
418
|
-
live: queries.live
|
|
419
|
-
liveValue
|
|
437
|
+
live: queries.live,
|
|
438
|
+
liveValue,
|
|
439
|
+
choiceReason: uiLiveChoiceReason(t, queries.live),
|
|
420
440
|
onChoose:
|
|
421
441
|
!queries.ready || queries.reading
|
|
422
442
|
? undefined
|
|
@@ -452,7 +472,7 @@ function UiBlockBody({
|
|
|
452
472
|
pending.current = false;
|
|
453
473
|
});
|
|
454
474
|
}
|
|
455
|
-
:
|
|
475
|
+
: undefined
|
|
456
476
|
};
|
|
457
477
|
const unknown = block.catalogVersion !== UI_CATALOG_VERSION;
|
|
458
478
|
const failed = !unknown && !block.nodes.length && block.diagnostics.length > 0;
|
|
@@ -471,17 +491,8 @@ function UiBlockBody({
|
|
|
471
491
|
{unknown || failed ? (
|
|
472
492
|
<ReplyMarkdown text={block.fallback} streaming={false} />
|
|
473
493
|
) : (
|
|
474
|
-
<CitationContext
|
|
475
|
-
|
|
476
|
-
sources: sourceTargets(evaluation.nodes),
|
|
477
|
-
reveal: element
|
|
478
|
-
? (source) => {
|
|
479
|
-
void revealUiSource(element, source.id, source.ancestors);
|
|
480
|
-
}
|
|
481
|
-
: undefined
|
|
482
|
-
}}
|
|
483
|
-
>
|
|
484
|
-
<UiNodes nodes={evaluation.nodes} parent={BLOCK} texts={writtenTexts(block.nodes)} context={rendering} />
|
|
494
|
+
<CitationContext value={citations}>
|
|
495
|
+
<UiNodes nodes={evaluation.nodes} parent={BLOCK} texts={texts} context={rendering} />
|
|
485
496
|
</CitationContext>
|
|
486
497
|
)}
|
|
487
498
|
</UiBlockFrame>
|
|
@@ -500,7 +511,7 @@ export function UiReply({
|
|
|
500
511
|
}: {
|
|
501
512
|
text: string;
|
|
502
513
|
blocks: readonly UiBlock[];
|
|
503
|
-
context: Omit<
|
|
514
|
+
context: Omit<UiBlockIdentity, 'blockId'>;
|
|
504
515
|
answers?: Readonly<Record<string, ChatUiAnswer>>;
|
|
505
516
|
queries?: ChatUiQueryState;
|
|
506
517
|
reply?: ComponentProps<typeof ReplyMarkdown>['reply'];
|
|
@@ -515,13 +526,12 @@ export function UiReply({
|
|
|
515
526
|
</Fragment>
|
|
516
527
|
)
|
|
517
528
|
) : (
|
|
518
|
-
<ErrorBoundary key={
|
|
529
|
+
<ErrorBoundary key={JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])} label="UI block">
|
|
519
530
|
<UiBlockBody
|
|
520
|
-
key={JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])}
|
|
521
531
|
block={part.block}
|
|
522
532
|
frozen={queries?.blocks[part.block.id]}
|
|
523
533
|
answered={answers?.[part.block.id]}
|
|
524
|
-
context={{
|
|
534
|
+
context={{ scopeId: context.scopeId, chatId: context.chatId, itemId: context.itemId, phase: context.phase, blockId: part.block.id }}
|
|
525
535
|
/>
|
|
526
536
|
</ErrorBoundary>
|
|
527
537
|
)
|
|
@@ -1,8 +1,43 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import type { UiRenderContext } from './render-context';
|
|
3
3
|
|
|
4
|
+
/* Values that last as long as the page, past `limit` forgetting the one used longest ago. */
|
|
5
|
+
export class UiPageMemory<Value> {
|
|
6
|
+
private readonly entries = new Map<string, Value>();
|
|
7
|
+
private readonly limit: number;
|
|
8
|
+
|
|
9
|
+
constructor(limit: number) {
|
|
10
|
+
this.limit = limit;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
has(key: string): boolean {
|
|
14
|
+
return this.entries.has(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Reading a value counts as using it. */
|
|
18
|
+
get(key: string): Value | undefined {
|
|
19
|
+
const value = this.entries.get(key);
|
|
20
|
+
if (value !== undefined) {
|
|
21
|
+
this.set(key, value);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
set(key: string, value: Value): void {
|
|
27
|
+
this.entries.delete(key);
|
|
28
|
+
this.entries.set(key, value);
|
|
29
|
+
if (this.entries.size > this.limit) {
|
|
30
|
+
this.entries.delete(this.entries.keys().next().value!);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get size(): number {
|
|
35
|
+
return this.entries.size;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
4
39
|
// Which tab or section a person opened, so a block the thread unmounts while scrolling comes back as they left it.
|
|
5
|
-
const remembered = new
|
|
40
|
+
const remembered = new UiPageMemory<unknown>(512);
|
|
6
41
|
|
|
7
42
|
/* State of one node that lasts as long as the page, such as a fold; local input state belongs to the runtime instead. */
|
|
8
43
|
export function useBlockLocal<Value>(context: UiRenderContext, nodeId: string, initial: () => Value): [Value, (next: Value) => void] {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { TFunction } from 'i18next';
|
|
1
2
|
import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
|
|
3
|
+
import type { UiLiveStatus } from './render-context';
|
|
2
4
|
|
|
3
5
|
/* The text a node holds, every `$text` below it joined as written, such as the code of a CodeBlock. */
|
|
4
6
|
export function uiNodeText(node: UiViewNode): string {
|
|
@@ -57,3 +59,61 @@ export async function revealUiSource(root: ParentNode, nodeId: string, ancestors
|
|
|
57
59
|
row.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' });
|
|
58
60
|
return true;
|
|
59
61
|
}
|
|
62
|
+
|
|
63
|
+
/* The reason codes this version says in its own words; any other code reads as the reason the machine gave. */
|
|
64
|
+
const UI_REASON_CODES = new Set([
|
|
65
|
+
'access-unavailable',
|
|
66
|
+
'block-stale',
|
|
67
|
+
'busy',
|
|
68
|
+
'link-unchecked',
|
|
69
|
+
'link-unsupported',
|
|
70
|
+
'links-unsupported',
|
|
71
|
+
'no-unique-match',
|
|
72
|
+
'outside-project',
|
|
73
|
+
'query-undeclared',
|
|
74
|
+
'refresh-limit',
|
|
75
|
+
'result-too-large',
|
|
76
|
+
'snapshot-too-large',
|
|
77
|
+
'source-unregistered',
|
|
78
|
+
'stale-read',
|
|
79
|
+
'target-unavailable',
|
|
80
|
+
'timed-out',
|
|
81
|
+
'unreadable'
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/* Why a reading failed or a link stayed plain, in the person's language where the code is known. */
|
|
85
|
+
export function uiReasonText(t: TFunction<'agent-chat'>, code: string | undefined, reason: string | undefined): string | undefined {
|
|
86
|
+
return code !== undefined && UI_REASON_CODES.has(code) ? t(`blocks.live.reasons.${code}`) : reason;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* A number with its unit, where a percent sign hugs the number and every other unit stands apart
|
|
91
|
+
* from it, by a space that never lets the two wrap apart.
|
|
92
|
+
*/
|
|
93
|
+
export function uiWithUnit(text: string, unit: string | undefined): string {
|
|
94
|
+
if (unit === undefined || unit === '') {
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
return unit === '%' ? `${text}${unit}` : `${text}\u00a0${unit}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Why the choices of a live block are closed: its data is still being read, or the reason a reading failed. Null while nothing live holds them. */
|
|
101
|
+
export function uiLiveChoiceReason(t: TFunction<'agent-chat'>, live: UiLiveStatus | null | undefined): string | null {
|
|
102
|
+
if (live === null || live === undefined || live.state === 'fresh') {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (live.state === 'reading') {
|
|
106
|
+
return t('blocks.choice.reading');
|
|
107
|
+
}
|
|
108
|
+
const source = live.source ?? live.sources[0] ?? '';
|
|
109
|
+
return uiReasonText(t, live.code, live.reason) ?? t(live.state === 'failed' ? 'blocks.live.failed' : 'blocks.live.refused', { source });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* The host of an address without its `www.`, which is how a source or a link in prose says where it goes. */
|
|
113
|
+
export function uiUrlDomain(url: string): string {
|
|
114
|
+
try {
|
|
115
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
116
|
+
} catch {
|
|
117
|
+
return url;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -3,7 +3,7 @@ import { ChartRenderer } from './renderers/chart';
|
|
|
3
3
|
import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices';
|
|
4
4
|
import { CodeBlockRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content';
|
|
5
5
|
import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data';
|
|
6
|
-
import { ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs';
|
|
6
|
+
import { ButtonRenderer, ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs';
|
|
7
7
|
import { CommitRenderer, DiffRenderer, FileRenderer, NodeRenderer } from './renderers/links';
|
|
8
8
|
import { SectionRenderer, SectionsRenderer, TabRenderer, TabsRenderer } from './renderers/structure';
|
|
9
9
|
import { CalloutRenderer, ProgressRenderer, StepRenderer, StepsRenderer, SummaryRenderer, TagRenderer } from './renderers/text';
|
|
@@ -45,6 +45,7 @@ export const UI_RENDERERS: UiRenderers = {
|
|
|
45
45
|
Slider: SliderRenderer,
|
|
46
46
|
Segmented: SegmentedRenderer,
|
|
47
47
|
Option: null,
|
|
48
|
+
Button: ButtonRenderer,
|
|
48
49
|
Show: null,
|
|
49
50
|
Each: null,
|
|
50
51
|
Choices: ChoicesRenderer,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createContext, type ReactNode } from 'react';
|
|
2
2
|
import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
|
|
3
|
-
|
|
4
|
-
export type { UiLinkTarget } from '@adecore/intelligent-ui/links';
|
|
5
3
|
import type { UiLinkTarget } from '@adecore/intelligent-ui/links';
|
|
6
4
|
|
|
5
|
+
export type { UiLinkTarget };
|
|
6
|
+
|
|
7
7
|
/* How the host lets a link be drawn: a chip it can open, or plain text it cannot. */
|
|
8
8
|
export interface UiLink {
|
|
9
9
|
state: 'chip' | 'plain';
|
|
@@ -11,6 +11,8 @@ export interface UiLink {
|
|
|
11
11
|
label?: string;
|
|
12
12
|
/* Why a plain link is not a chip, in its tooltip. */
|
|
13
13
|
reason?: string;
|
|
14
|
+
/* The stable code of that reason, which a client words itself where it knows it. */
|
|
15
|
+
code?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/* The Choice that answered a block. `sending` already reads as sent, so a second click does nothing. */
|
|
@@ -30,6 +32,8 @@ export interface UiLiveStatus {
|
|
|
30
32
|
source?: string;
|
|
31
33
|
/* Why a reading failed, in its tooltip. */
|
|
32
34
|
reason?: string;
|
|
35
|
+
/* The stable code of that reason, which a client words itself where it knows it. */
|
|
36
|
+
code?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/* Live history is separate from the comparison the agent supplies in Stat.previous. */
|
|
@@ -57,6 +61,8 @@ export interface UiRenderContext {
|
|
|
57
61
|
openLink?(target: UiLinkTarget): void;
|
|
58
62
|
openUrl?(url: string): void;
|
|
59
63
|
onChoose?(nodeId: string): void;
|
|
64
|
+
/* Why the choices are closed while `onChoose` is absent, such as live data still being read; without it they say they are not available. */
|
|
65
|
+
choiceReason?: string | null;
|
|
60
66
|
/* Table cells use the evaluated rows index and column key. */
|
|
61
67
|
liveValue?(nodeId: string, prop: string, path?: readonly [row: number, key: string]): UiLiveValue | undefined;
|
|
62
68
|
}
|
|
@@ -6,6 +6,7 @@ import type { UiProps } from '@adecore/intelligent-ui';
|
|
|
6
6
|
import { SegmentBar, useMeasuredWidth } from '@adecore/ui';
|
|
7
7
|
import { formatRounded } from '@adecore/ui/format';
|
|
8
8
|
import { niceCeiling, UI_CHART_ROWS, uiChartData, type UiChartData } from '../chart-data';
|
|
9
|
+
import { uiWithUnit } from '../node-text';
|
|
9
10
|
import type { UiRendererProps } from '../render-context';
|
|
10
11
|
import { UiFallbackPart } from '../UiFallbackPart';
|
|
11
12
|
|
|
@@ -28,11 +29,7 @@ function seriesColor(index: number): string {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function valueText(value: number | null, unit: string | undefined): string {
|
|
31
|
-
|
|
32
|
-
return '';
|
|
33
|
-
}
|
|
34
|
-
const text = formatRounded(value, 2);
|
|
35
|
-
return unit === undefined || unit === '' ? text : unit === '%' ? `${text}%` : `${text} ${unit}`;
|
|
32
|
+
return value === null ? '' : uiWithUnit(formatRounded(value, 2), unit);
|
|
36
33
|
}
|
|
37
34
|
|
|
38
35
|
/* The height a chart will take, which its place holds while the node streams. */
|
|
@@ -210,7 +207,7 @@ function VerticalChart({ chart, kind, unit, width }: { chart: UiChartData; kind:
|
|
|
210
207
|
/>
|
|
211
208
|
{chart.labels.length <= DOTS_UP_TO &&
|
|
212
209
|
series.values.map((value, index) =>
|
|
213
|
-
value === null ? null : <circle key={index} cx={center(index)} cy={y(value)} r={
|
|
210
|
+
value === null ? null : <circle key={index} cx={center(index)} cy={y(value)} r={3} fill="currentColor" />
|
|
214
211
|
)}
|
|
215
212
|
</g>
|
|
216
213
|
))}
|
|
@@ -235,7 +232,8 @@ function VerticalChart({ chart, kind, unit, width }: { chart: UiChartData; kind:
|
|
|
235
232
|
|
|
236
233
|
/* A row of 24px per category, its label beside the bars. */
|
|
237
234
|
function HorizontalChart({ chart, unit }: { chart: UiChartData; unit: string | undefined }) {
|
|
238
|
-
|
|
235
|
+
// Bars grow right from zero only, so a chart of only negative values has no length to share and keeps a unit scale.
|
|
236
|
+
const high = niceCeiling(chart.max) || 1;
|
|
239
237
|
const single = chart.series.length === 1;
|
|
240
238
|
return (
|
|
241
239
|
<div aria-hidden className="flex flex-col">
|