@adecore/agents-react 0.21.2 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/model-requirement.d.ts +6 -0
- package/dist/agents/model-requirement.js +26 -0
- package/dist/chat/chat-client.d.ts +2 -1
- package/dist/chat/chat-client.js +2 -0
- package/dist/chat/drafts.d.ts +2 -1
- package/dist/chat/drafts.js +15 -5
- package/dist/chat/logic/picker-entries.d.ts +10 -0
- package/dist/chat/logic/picker-entries.js +19 -0
- package/dist/chat/logic/timeline.d.ts +5 -0
- package/dist/chat/logic/timeline.js +10 -1
- package/dist/chat/prompt-hold.d.ts +8 -0
- package/dist/chat/prompt-hold.js +16 -0
- package/dist/chat/quote.d.ts +4 -0
- package/dist/chat/quote.js +19 -0
- package/dist/chat/stash.d.ts +8 -2
- package/dist/chat/stash.js +21 -5
- package/dist/chat/subagent-list.d.ts +7 -1
- package/dist/chat/subagent-list.js +13 -1
- package/dist/chat/timeline-scroll.d.ts +6 -2
- package/dist/chat/timeline-scroll.js +12 -7
- package/dist/chat/ui/ChatActivity.js +22 -8
- package/dist/chat/ui/Composer.js +103 -19
- package/dist/chat/ui/ComposerInput.d.ts +3 -1
- package/dist/chat/ui/ComposerInput.js +13 -4
- package/dist/chat/ui/Pickers.js +26 -18
- package/dist/chat/ui/PromptComposer.d.ts +8 -1
- package/dist/chat/ui/PromptComposer.js +18 -6
- package/dist/chat/ui/QuoteButton.js +2 -2
- package/dist/chat/ui/SubagentControls.d.ts +4 -2
- package/dist/chat/ui/SubagentControls.js +12 -8
- package/dist/chat/ui/SubagentInfo.d.ts +1 -1
- package/dist/chat/ui/SubagentInfo.js +9 -15
- package/dist/chat/ui/SubagentStopButton.js +2 -1
- package/dist/chat/ui/Timeline.js +24 -3
- package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
- package/dist/chat/ui/composer/menu-aria.js +18 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +3 -3
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +1 -1
- package/dist/chat/ui/intelligent-ui/chart-data.d.ts +22 -2
- package/dist/chat/ui/intelligent-ui/chart-data.js +83 -19
- package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +1 -1
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +99 -35
- package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +1 -1
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +8 -2
- package/dist/chat/ui/intelligent-ui/renderers/content.js +5 -3
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +9 -4
- package/dist/chat/ui/intelligent-ui/renderers/links.js +1 -1
- package/dist/chat/ui/intelligent-ui/renderers/text.js +5 -2
- package/dist/chat/ui/intelligent-ui/table-data.js +4 -4
- package/dist/chat/ui/intelligent-ui/use-truncated.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/use-truncated.js +28 -0
- package/dist/chat/ui/quote-selection.d.ts +5 -2
- package/dist/chat/ui/quote-selection.js +3 -2
- package/dist/chat/ui/rows/MessageRows.js +24 -5
- package/dist/chat/ui/rows/Rows.js +1 -1
- package/dist/chat/ui/rows/WorkRows.d.ts +3 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -4
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +39 -10
- package/dist/locales/nl/agent-chat.json +39 -10
- package/dist/state/chats.d.ts +2 -1
- package/dist/state/chats.js +19 -1
- package/package.json +29 -4
- package/src/agents/model-requirement.ts +36 -0
- package/src/chat/chat-client.ts +5 -0
- package/src/chat/drafts.ts +27 -7
- package/src/chat/logic/picker-entries.ts +25 -0
- package/src/chat/logic/timeline.ts +17 -2
- package/src/chat/prompt-hold.ts +27 -0
- package/src/chat/quote.ts +20 -0
- package/src/chat/stash.ts +31 -5
- package/src/chat/subagent-list.ts +19 -5
- package/src/chat/timeline-scroll.ts +24 -10
- package/src/chat/ui/ChatActivity.tsx +35 -9
- package/src/chat/ui/Composer.tsx +197 -52
- package/src/chat/ui/ComposerInput.tsx +18 -3
- package/src/chat/ui/Pickers.tsx +51 -30
- package/src/chat/ui/PromptComposer.tsx +37 -8
- package/src/chat/ui/QuoteButton.tsx +3 -2
- package/src/chat/ui/SubagentControls.tsx +22 -19
- package/src/chat/ui/SubagentInfo.tsx +24 -42
- package/src/chat/ui/SubagentStopButton.tsx +2 -1
- package/src/chat/ui/Timeline.tsx +26 -4
- package/src/chat/ui/composer/menu-aria.ts +29 -0
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +5 -5
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +6 -1
- package/src/chat/ui/intelligent-ui/chart-data.ts +106 -20
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +237 -72
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +9 -4
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +31 -15
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +16 -5
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +3 -3
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +18 -8
- package/src/chat/ui/intelligent-ui/table-data.ts +4 -4
- package/src/chat/ui/intelligent-ui/use-truncated.ts +33 -0
- package/src/chat/ui/quote-selection.ts +6 -4
- package/src/chat/ui/rows/MessageRows.tsx +44 -5
- package/src/chat/ui/rows/Rows.tsx +3 -1
- package/src/chat/ui/rows/WorkRows.tsx +15 -1
- package/src/host.ts +3 -0
- package/src/locales/en/agent-chat.json +39 -10
- package/src/locales/nl/agent-chat.json +39 -10
- package/src/state/chats.ts +31 -2
|
@@ -2,9 +2,10 @@ import { useId, useState } from 'react';
|
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { ArrowRight, Check } from 'lucide-react';
|
|
5
|
-
import type
|
|
5
|
+
import { uiChoiceLabel, type UiProps } from '@adecore/intelligent-ui';
|
|
6
6
|
import { DisabledReason, Icon, Spinner, Tooltip } from '@adecore/ui';
|
|
7
7
|
import type { UiRendererProps } from '../render-context';
|
|
8
|
+
import { UiFallbackPart } from '../UiFallbackPart';
|
|
8
9
|
import { useLater } from '../use-later';
|
|
9
10
|
|
|
10
11
|
/* A context this long may run past its two lines, so the row offers all of it in a tooltip. */
|
|
@@ -28,7 +29,8 @@ export function ChoicesRenderer({ children, context }: UiRendererProps<UiProps<'
|
|
|
28
29
|
/*
|
|
29
30
|
* A row that shows what it sends: the label, and under it the context the agent receives. It stays
|
|
30
31
|
* closed while the reply streams, once the block is answered and without a host to send it, and
|
|
31
|
-
* remains focusable then so a screen reader hears why.
|
|
32
|
+
* remains focusable then so a screen reader hears why. A finished Choice the host would refuse for
|
|
33
|
+
* its label is no row at all, but its text.
|
|
32
34
|
*/
|
|
33
35
|
export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiProps<'Choice'>>) {
|
|
34
36
|
const { t } = useTranslation('agent-chat');
|
|
@@ -41,6 +43,9 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
|
|
|
41
43
|
const failed = answer === null && context.failedChoiceId === node.id;
|
|
42
44
|
const closed = waiting || unavailable || answer !== null;
|
|
43
45
|
const slow = useLater(chosen && answer.state === 'sending', SPINNER_AFTER_MS);
|
|
46
|
+
if (!waiting && uiChoiceLabel(node) === null) {
|
|
47
|
+
return <UiFallbackPart fallback={node.fallback} problem={{ kind: 'failed' }} />;
|
|
48
|
+
}
|
|
44
49
|
const reason = waiting
|
|
45
50
|
? t('blocks.choice.waiting')
|
|
46
51
|
: answer === null && unavailable
|
|
@@ -67,9 +72,9 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
|
|
|
67
72
|
>
|
|
68
73
|
<Icon icon={ArrowRight} size={14} className={clsx('mt-1 shrink-0', primary === true ? 'text-accent' : 'text-text-faint')} />
|
|
69
74
|
<span className="flex min-w-0 grow flex-col">
|
|
70
|
-
<span className="text-sm font-semibold text-text">{children}</span>
|
|
75
|
+
<span className="text-sm font-semibold wrap-anywhere text-text">{children}</span>
|
|
71
76
|
{described && (
|
|
72
|
-
<span id={descriptionId} className="line-clamp-2 text-xs text-text-muted">
|
|
77
|
+
<span id={descriptionId} className="line-clamp-2 text-xs wrap-anywhere text-text-muted">
|
|
73
78
|
{detail}
|
|
74
79
|
</span>
|
|
75
80
|
)}
|
|
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|
|
2
2
|
import { createContext, useContext, useId } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import type { UiProps } from '@adecore/intelligent-ui';
|
|
5
|
-
import { PreviewCard } from '@adecore/ui';
|
|
5
|
+
import { PreviewCard, Tooltip } from '@adecore/ui';
|
|
6
6
|
import { formatNumber } from '@adecore/ui/format';
|
|
7
7
|
import { chatHost } from '../../../../host';
|
|
8
8
|
import { CodeBlock } from '../../CodeBlock';
|
|
@@ -10,6 +10,7 @@ import { CodeStreamingContext } from '../../code-streaming';
|
|
|
10
10
|
import { ImageThumb } from '../../ImageView';
|
|
11
11
|
import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text';
|
|
12
12
|
import type { UiRendererProps } from '../render-context';
|
|
13
|
+
import { useTruncated } from '../use-truncated';
|
|
13
14
|
|
|
14
15
|
// The number of every source by its node id, which the list counts in its own order.
|
|
15
16
|
const SourceNumbers = createContext<ReadonlyMap<string, number>>(new Map());
|
|
@@ -115,26 +116,41 @@ export function SourceRenderer({ node, context }: UiRendererProps<UiProps<'Sourc
|
|
|
115
116
|
const number = useContext(SourceNumbers).get(node.id);
|
|
116
117
|
const { title, url } = node.props;
|
|
117
118
|
const { openUrl } = context;
|
|
119
|
+
const [measureDomain, domainCut] = useTruncated<HTMLSpanElement>();
|
|
118
120
|
const face = (
|
|
119
121
|
<>
|
|
120
|
-
<span className="w-5 shrink-0 text-right text-text-faint tabular-nums">{number}</span>
|
|
121
|
-
<span className="min-w-0
|
|
122
|
-
<span className="shrink-0 text-text-faint">
|
|
122
|
+
<span className="w-5 shrink-0 self-start text-right text-text-faint tabular-nums">{number}</span>
|
|
123
|
+
<span className="min-w-0 grow wrap-anywhere text-text">{title}</span>
|
|
124
|
+
<span ref={measureDomain} className="max-w-1/2 min-w-0 shrink-0 self-start truncate text-text-faint">
|
|
125
|
+
{uiUrlDomain(url)}
|
|
126
|
+
</span>
|
|
123
127
|
</>
|
|
124
128
|
);
|
|
125
129
|
return (
|
|
126
130
|
<li data-ui-source={node.id} tabIndex={-1} className="chat-ui-source scroll-my-2 rounded-md">
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
{/* The title wraps and the domain may be cut short, so the tooltip says both whole; a row that opens nothing takes focus only while it cuts. */}
|
|
132
|
+
<Tooltip
|
|
133
|
+
label={
|
|
134
|
+
<>
|
|
135
|
+
<span className="block">{title}</span>
|
|
136
|
+
<span className="block text-text-muted">{url}</span>
|
|
137
|
+
</>
|
|
138
|
+
}
|
|
139
|
+
>
|
|
140
|
+
{openUrl === undefined ? (
|
|
141
|
+
<span tabIndex={domainCut ? 0 : undefined} className="flex min-h-8 items-start gap-2 rounded-md px-2 py-2 text-xs">
|
|
142
|
+
{face}
|
|
143
|
+
</span>
|
|
144
|
+
) : (
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
className="flex min-h-8 w-full items-start gap-2 rounded-md px-2 py-2 text-left text-xs hover:bg-surface-hover"
|
|
148
|
+
onClick={() => openUrl(url)}
|
|
149
|
+
>
|
|
150
|
+
{face}
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
</Tooltip>
|
|
138
154
|
</li>
|
|
139
155
|
);
|
|
140
156
|
}
|
|
@@ -2,9 +2,10 @@ import { createContext, useContext, type MouseEvent, type ReactNode } from 'reac
|
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import type { UiBinding, UiProps, UiViewNode } from '@adecore/intelligent-ui';
|
|
5
|
-
import { Button, Checkbox, Segmented, Slider, Switch } from '@adecore/ui';
|
|
5
|
+
import { Button, Checkbox, Segmented, Slider, Switch, Tooltip } from '@adecore/ui';
|
|
6
6
|
import { uiChildrenOf, uiNodeLabel, uiWithUnit } from '../node-text';
|
|
7
7
|
import type { UiRenderContext, UiRendererProps } from '../render-context';
|
|
8
|
+
import { useTruncated } from '../use-truncated';
|
|
8
9
|
|
|
9
10
|
type Scalar = UiProps<'Item'>['value'];
|
|
10
11
|
|
|
@@ -106,6 +107,9 @@ export function SliderRenderer({ node, context }: UiRendererProps<UiProps<'Slide
|
|
|
106
107
|
);
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/* A label longer than this reads as a sentence, which wraps badly beside another; the options then stand in a column. */
|
|
111
|
+
const SIDE_BY_SIDE_LABEL = 24;
|
|
112
|
+
|
|
109
113
|
/* Left aligned, never the full width. Each Option is a segment by its place, since a value may be any scalar. */
|
|
110
114
|
export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Segmented'>>) {
|
|
111
115
|
const { t } = useTranslation('agent-chat');
|
|
@@ -119,6 +123,7 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
|
|
|
119
123
|
value={picked}
|
|
120
124
|
options={options}
|
|
121
125
|
label={t('blocks.options')}
|
|
126
|
+
orientation={options.some((option) => option.label.length > SIDE_BY_SIDE_LABEL) ? 'vertical' : 'horizontal'}
|
|
122
127
|
disabled={binding === null}
|
|
123
128
|
onValueChange={(id) => {
|
|
124
129
|
const option = options[Number(id)];
|
|
@@ -131,14 +136,20 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
|
|
|
131
136
|
);
|
|
132
137
|
}
|
|
133
138
|
|
|
134
|
-
/* Runs its local action once its own node closed; like an input, it closes once the block is answered. */
|
|
139
|
+
/* Runs its local action once its own node closed; like an input, it closes once the block is answered. A label cut to the button's one line reads whole in its tooltip. */
|
|
135
140
|
export function ButtonRenderer({ node, children, context }: UiRendererProps<UiProps<'Button'>>) {
|
|
136
141
|
const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
|
|
142
|
+
const label = uiNodeLabel(node);
|
|
143
|
+
const [measure, truncated] = useTruncated<HTMLSpanElement>();
|
|
137
144
|
return (
|
|
138
145
|
<div className={clsx('px-2', !node.complete && PENDING)}>
|
|
139
|
-
<
|
|
140
|
-
{
|
|
141
|
-
|
|
146
|
+
<Tooltip label={label} disabled={!truncated}>
|
|
147
|
+
<Button variant="secondary" size="sm" className="max-w-full" disabled={!enabled} onClick={() => node.onAction?.()}>
|
|
148
|
+
<span ref={measure} className="min-w-0 truncate">
|
|
149
|
+
{children}
|
|
150
|
+
</span>
|
|
151
|
+
</Button>
|
|
152
|
+
</Tooltip>
|
|
142
153
|
</div>
|
|
143
154
|
);
|
|
144
155
|
}
|
|
@@ -115,11 +115,11 @@ function LinkRow({ target, context, described, children }: { target: UiLinkTarge
|
|
|
115
115
|
return <UiLinkChip target={target} context={context} />;
|
|
116
116
|
}
|
|
117
117
|
return (
|
|
118
|
-
<div className="flex min-w-0 items-baseline gap-2 px-2 text-sm">
|
|
119
|
-
<span className="
|
|
118
|
+
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 px-2 text-sm">
|
|
119
|
+
<span className="max-w-full min-w-0">
|
|
120
120
|
<UiLinkChip target={target} context={context} />
|
|
121
121
|
</span>
|
|
122
|
-
<span className="min-w-0 text-text-muted">{children}</span>
|
|
122
|
+
<span className="min-w-0 wrap-break-word text-text-muted">{children}</span>
|
|
123
123
|
</div>
|
|
124
124
|
);
|
|
125
125
|
}
|
|
@@ -31,20 +31,27 @@ function isLive(context: UiRenderContext): boolean {
|
|
|
31
31
|
return context.phase === 'streaming' || (context.live !== undefined && context.live !== null && context.live.state !== 'refused');
|
|
32
32
|
}
|
|
33
33
|
|
|
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;
|
|
36
|
+
|
|
34
37
|
/* The head of the block when it is the first node, a subheading without an icon further down. */
|
|
35
38
|
export function SummaryRenderer({ node, children }: UiRendererProps<UiProps<'Summary'>>) {
|
|
36
39
|
const headId = useContext(UiHeadContext);
|
|
37
40
|
const head = headId === undefined || headId === node.id;
|
|
38
41
|
const { tone, badge } = node.props;
|
|
42
|
+
const tag = badge !== undefined && badge !== '' && badge.length <= BADGE_AS_TAG;
|
|
39
43
|
return (
|
|
40
44
|
<div className="flex items-start gap-2 px-2">
|
|
41
45
|
{head && tone !== undefined && <Icon icon={UI_TONE_ICONS[tone]} size={16} className={clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone])} />}
|
|
42
|
-
<div className="min-w-0 grow
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
|
|
47
|
-
|
|
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>
|
|
52
|
+
)}
|
|
53
|
+
{!tag && badge !== undefined && badge !== '' && <p className="w-full text-xs wrap-anywhere text-text-muted">{badge}</p>}
|
|
54
|
+
</div>
|
|
48
55
|
</div>
|
|
49
56
|
);
|
|
50
57
|
}
|
|
@@ -117,8 +124,11 @@ export function StepRenderer({ node, children, context }: UiRendererProps<UiProp
|
|
|
117
124
|
)}
|
|
118
125
|
</span>
|
|
119
126
|
<span className="sr-only">{t(`blocks.step.${state}`)}</span>
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
{/* 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>}
|
|
131
|
+
</span>
|
|
122
132
|
</li>
|
|
123
133
|
);
|
|
124
134
|
}
|
|
@@ -21,8 +21,8 @@ export type UiTableCell =
|
|
|
21
21
|
|
|
22
22
|
/* Rows shown before a person asks for all of them. */
|
|
23
23
|
export const UI_TABLE_ROWS = 50;
|
|
24
|
-
/*
|
|
25
|
-
const
|
|
24
|
+
/* The furthest a JavaScript Date reaches from the epoch either way, in milliseconds. */
|
|
25
|
+
const DATE_RANGE = 8.64e15;
|
|
26
26
|
const NUMERIC_KINDS: ReadonlySet<UiColumnKind> = new Set(['number', 'bytes', 'duration']);
|
|
27
27
|
|
|
28
28
|
export function isNumericColumn(column: UiTableColumn): boolean {
|
|
@@ -51,7 +51,7 @@ export function uiTableColumns(node: UiViewNode<UiProps<'Table'>>): UiTableColum
|
|
|
51
51
|
const keys: string[] = [];
|
|
52
52
|
for (const row of rows) {
|
|
53
53
|
for (const key of Object.keys(row)) {
|
|
54
|
-
if (!keys.includes(key)
|
|
54
|
+
if (!keys.includes(key)) {
|
|
55
55
|
keys.push(key);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -92,7 +92,7 @@ export function uiTableCell(value: unknown, column: UiTableColumn): UiTableCell
|
|
|
92
92
|
}
|
|
93
93
|
case 'date': {
|
|
94
94
|
const at = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
95
|
-
return Number.isFinite(at) ? { kind: 'date', at } : { kind: 'text', text: textOf(value) };
|
|
95
|
+
return Number.isFinite(at) && Math.abs(at) <= DATE_RANGE ? { kind: 'date', at } : { kind: 'text', text: textOf(value) };
|
|
96
96
|
}
|
|
97
97
|
case 'file':
|
|
98
98
|
return typeof value === 'string' ? { kind: 'file', path: value } : { kind: 'text', text: textOf(value) };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
function cut(element: HTMLElement): boolean {
|
|
4
|
+
return element.scrollWidth > element.clientWidth + 1;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Whether an element's text is cut short where it stands, for a tooltip or a list of values that shows
|
|
9
|
+
* it whole exactly then. `within` asks the same of every `.truncate` element inside it instead. Measured
|
|
10
|
+
* again when the box resizes or the text changes, since neither moves the other.
|
|
11
|
+
*/
|
|
12
|
+
export function useTruncated<T extends HTMLElement>(within = false): [(element: T | null) => (() => void) | undefined, boolean] {
|
|
13
|
+
const [truncated, setTruncated] = useState(false);
|
|
14
|
+
const measure = useCallback(
|
|
15
|
+
(element: T | null) => {
|
|
16
|
+
if (element === null || typeof ResizeObserver === 'undefined') {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const check = () => setTruncated(within ? [...element.querySelectorAll<HTMLElement>('.truncate')].some(cut) : cut(element));
|
|
20
|
+
check();
|
|
21
|
+
const resize = new ResizeObserver(check);
|
|
22
|
+
resize.observe(element);
|
|
23
|
+
const text = new MutationObserver(check);
|
|
24
|
+
text.observe(element, { characterData: true, childList: true, subtree: true });
|
|
25
|
+
return () => {
|
|
26
|
+
resize.disconnect();
|
|
27
|
+
text.disconnect();
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
[within]
|
|
31
|
+
);
|
|
32
|
+
return [measure, truncated];
|
|
33
|
+
}
|
|
@@ -4,7 +4,8 @@ import { quoteOf } from '../quote';
|
|
|
4
4
|
/* The text of an answer (`rows/MessageRows.tsx`), the only part of a thread a selection quotes from. */
|
|
5
5
|
const ANSWER = '[data-quote-answer]';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/* `itemId` is the reply the quote came from, when the selection sits in a row that names one. */
|
|
8
|
+
export type QuoteTaker = (quote: string, itemId: string | null) => void;
|
|
8
9
|
|
|
9
10
|
/* Registers the composer below a thread as the one its quote button hands a quote to, until the returned call. */
|
|
10
11
|
export const QuoteTakerContext = createContext<((take: QuoteTaker) => () => void) | null>(null);
|
|
@@ -35,8 +36,8 @@ function selectsOutside(range: Range, answer: Element): boolean {
|
|
|
35
36
|
return false;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/* The quote the selection makes, when it lies in one answer of this thread. */
|
|
39
|
-
export function selectedAnswerQuote(selection: Selection | null, thread: HTMLElement | null): string | null {
|
|
39
|
+
/* The quote the selection makes, when it lies in one answer of this thread, and the reply it was taken from. */
|
|
40
|
+
export function selectedAnswerQuote(selection: Selection | null, thread: HTMLElement | null): { text: string; itemId: string | null } | null {
|
|
40
41
|
if (thread === null || selection === null || selection.isCollapsed || selection.rangeCount === 0) {
|
|
41
42
|
return null;
|
|
42
43
|
}
|
|
@@ -49,5 +50,6 @@ export function selectedAnswerQuote(selection: Selection | null, thread: HTMLEle
|
|
|
49
50
|
if (!inside && selectsOutside(range, answer)) {
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
const text = quoteOf(selection.toString());
|
|
54
|
+
return text === null ? null : { text, itemId: answer.closest('[data-find-item]')?.getAttribute('data-find-item') ?? null };
|
|
53
55
|
}
|
|
@@ -11,10 +11,13 @@ import type {
|
|
|
11
11
|
ChatUiChoiceOrigin,
|
|
12
12
|
ChatUserItem
|
|
13
13
|
} from '@adecore/agent-contracts';
|
|
14
|
+
import { WAITING_NOTE_PREFIX } from '@adecore/agent-contracts';
|
|
14
15
|
import { fileBadge, formatBytes, isImageAttachment } from '../../attachments';
|
|
15
16
|
import { useChatRow, useCurrentItem } from '../../../state/chats';
|
|
16
17
|
import { chatHost } from '../../../host';
|
|
17
18
|
import { useChatScope } from '../../../scope';
|
|
19
|
+
import { splitQuote } from '../../quote';
|
|
20
|
+
import { jumpToTimelineItem } from '../../timeline-scroll';
|
|
18
21
|
import { useProviders } from '../../../state/providers';
|
|
19
22
|
import { ChatReferenceChip } from '../ChatReferenceChip';
|
|
20
23
|
import { ImageThumb } from '../ImageView';
|
|
@@ -86,7 +89,19 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
|
|
|
86
89
|
const long = item.text.split('\n').length > USER_FOLD_LINES || item.text.length > USER_FOLD_CHARS;
|
|
87
90
|
const attachments = item.attachments ?? [];
|
|
88
91
|
const chats = item.chats ?? [];
|
|
92
|
+
const [missing, setMissing] = useState(false);
|
|
93
|
+
const { keyOf } = useChatScope();
|
|
89
94
|
useOpenForFind(item.id, 'text', setOpen);
|
|
95
|
+
// A quote only leads somewhere when the message still opens with the blockquote it was sent with.
|
|
96
|
+
const quoted = item.quoteSource === undefined ? null : splitQuote(item.text);
|
|
97
|
+
const source = item.quoteSource?.itemId;
|
|
98
|
+
|
|
99
|
+
function goToSource(): void {
|
|
100
|
+
if (source !== undefined) {
|
|
101
|
+
setMissing(false);
|
|
102
|
+
jumpToTimelineItem(keyOf(chatId), source, { flash: true, onMissing: () => setMissing(true) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
90
105
|
return (
|
|
91
106
|
<div data-find-item={item.id} className="flex flex-col items-end">
|
|
92
107
|
<MessageHeading>{t('rows.user.heading')}</MessageHeading>
|
|
@@ -112,7 +127,31 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
|
|
|
112
127
|
{item.text !== '' && (
|
|
113
128
|
<div className="relative max-w-[80%] rounded-2xl bg-surface-active px-3.5 py-2.5 text-sm wrap-anywhere text-text select-text">
|
|
114
129
|
<div data-find-field="text" className={clsx(long && !open && FOLD)}>
|
|
115
|
-
|
|
130
|
+
{quoted !== null && source !== undefined ? (
|
|
131
|
+
<>
|
|
132
|
+
{/* A div, since the quote is block markdown and a button holds none. */}
|
|
133
|
+
<Tooltip label={t('composer.quote.goToSource')}>
|
|
134
|
+
<div
|
|
135
|
+
role="button"
|
|
136
|
+
tabIndex={0}
|
|
137
|
+
className="mb-2 cursor-pointer rounded-sm border-l-2 border-border-strong pl-2.5 text-text-muted hover:bg-text/5 hover:text-text focus-visible:outline-2 focus-visible:outline-accent"
|
|
138
|
+
onClick={goToSource}
|
|
139
|
+
onKeyDown={(e) => {
|
|
140
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
goToSource();
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<MessageMarkdown text={quoted.quote} />
|
|
147
|
+
</div>
|
|
148
|
+
</Tooltip>
|
|
149
|
+
{missing && <div className="mb-2 text-xs text-text-faint">{t('rows.user.quoteGone')}</div>}
|
|
150
|
+
{quoted.rest !== '' && <MessageMarkdown text={quoted.rest} mentions={item.mentions} skills={item.skills} />}
|
|
151
|
+
</>
|
|
152
|
+
) : (
|
|
153
|
+
<MessageMarkdown text={item.text} mentions={item.mentions} skills={item.skills} />
|
|
154
|
+
)}
|
|
116
155
|
</div>
|
|
117
156
|
{long && (
|
|
118
157
|
<button className="mt-1 flex items-center gap-1 text-xs text-text-muted hover:text-text" onClick={() => setOpen((o) => !o)}>
|
|
@@ -364,7 +403,7 @@ export function NoteRow({ id, level, text, from }: { id: string; level: 'info' |
|
|
|
364
403
|
{open ? t('rows.note.hide') : t('rows.note.show')}
|
|
365
404
|
</button>
|
|
366
405
|
)}
|
|
367
|
-
{from !== undefined && <OpenChatButton chatId={from} />}
|
|
406
|
+
{from !== undefined && <OpenChatButton chatId={from} label={id.startsWith(WAITING_NOTE_PREFIX) ? 'openWaiting' : 'openFork'} />}
|
|
368
407
|
</div>
|
|
369
408
|
{open && rest !== '' && (
|
|
370
409
|
<div data-find-field="output" className="mt-1 text-text-muted select-text">
|
|
@@ -376,8 +415,8 @@ export function NoteRow({ id, level, text, from }: { id: string; level: 'info' |
|
|
|
376
415
|
);
|
|
377
416
|
}
|
|
378
417
|
|
|
379
|
-
/* "Open fork" on a note a fork sent, while that
|
|
380
|
-
function OpenChatButton({ chatId }: { chatId: string }) {
|
|
418
|
+
/* "Open fork" on a note a fork sent, or "Open to answer" on one about a waiting child, while that chat is still in the project. */
|
|
419
|
+
function OpenChatButton({ chatId, label }: { chatId: string; label: 'openFork' | 'openWaiting' }) {
|
|
381
420
|
const { t } = useTranslation('agent-chat');
|
|
382
421
|
const place = chatHost().useChatPlace(chatId);
|
|
383
422
|
if (place.title === null) {
|
|
@@ -385,7 +424,7 @@ function OpenChatButton({ chatId }: { chatId: string }) {
|
|
|
385
424
|
}
|
|
386
425
|
return (
|
|
387
426
|
<button className="text-text-muted hover:text-text" onClick={place.go}>
|
|
388
|
-
{t(
|
|
427
|
+
{t(`rows.note.${label}`)}
|
|
389
428
|
</button>
|
|
390
429
|
);
|
|
391
430
|
}
|
|
@@ -67,7 +67,9 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
|
|
|
67
67
|
case 'forks':
|
|
68
68
|
return <ForksRow chatId={chatId} turnId={row.turnId} />;
|
|
69
69
|
case 'changed-files':
|
|
70
|
-
return
|
|
70
|
+
return (
|
|
71
|
+
<ChangedFilesRow chatId={chatId} turnId={row.turnId} tools={row.tools} diff={row.diff} checkpoint={row.checkpoint} baseMoved={row.baseMoved} />
|
|
72
|
+
);
|
|
71
73
|
case 'approval':
|
|
72
74
|
return <ApprovalHistoryRow item={row.item} />;
|
|
73
75
|
case 'question':
|
|
@@ -4,6 +4,7 @@ import i18next from 'i18next';
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { ChevronRight, FileDiff, X } from 'lucide-react';
|
|
6
6
|
import type { ChatCheckpointDiff, ChatCheckpointFile, ChatFileChange, ChatToolItem, ChatTurnItem } from '@adecore/agent-contracts';
|
|
7
|
+
import type { BaseMoved } from '../../logic/timeline';
|
|
7
8
|
import { fileChanges, liveOutput, readImagePath, toolStartedAt, toolSummary, unifiedChanges, type FileChange } from '../../logic/tools';
|
|
8
9
|
import { useOpenForFind } from '../find-reveal';
|
|
9
10
|
import { chatHost } from '../../../host';
|
|
@@ -289,10 +290,13 @@ function ChangedFilesCard({
|
|
|
289
290
|
added,
|
|
290
291
|
deleted,
|
|
291
292
|
revealNonce = 0,
|
|
293
|
+
baseMoved,
|
|
292
294
|
children
|
|
293
295
|
}: {
|
|
294
296
|
count: number;
|
|
295
297
|
truncated?: boolean;
|
|
298
|
+
/* Set when the branch moved during the turn: the count is then what changed in the folder, not only the agent's work. */
|
|
299
|
+
baseMoved?: BaseMoved | undefined;
|
|
296
300
|
added?: number;
|
|
297
301
|
deleted?: number;
|
|
298
302
|
/* A jump from outside the chat that asked for this card; each new one opens it. */
|
|
@@ -315,13 +319,20 @@ function ChangedFilesCard({
|
|
|
315
319
|
onClick={() => setExpanded((value) => !value)}
|
|
316
320
|
>
|
|
317
321
|
<Icon icon={FileDiff} size={12} className="shrink-0" />
|
|
318
|
-
<span className="shrink-0 font-medium text-text">
|
|
322
|
+
<span className="shrink-0 font-medium text-text">
|
|
323
|
+
{baseMoved === undefined ? t('work.changedFiles', { count }) : t('work.folderChanges', { count })}
|
|
324
|
+
</span>
|
|
319
325
|
{truncated && <span className="min-w-0 truncate text-text-faint">{t('work.andMore')}</span>}
|
|
320
326
|
<span className="grow" />
|
|
321
327
|
{added !== undefined && <span className="text-chat-added tabular-nums">+{added}</span>}
|
|
322
328
|
{deleted !== undefined && <span className="text-chat-removed tabular-nums">-{deleted}</span>}
|
|
323
329
|
<Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', expanded && 'rotate-90')} />
|
|
324
330
|
</button>
|
|
331
|
+
{baseMoved !== undefined && (
|
|
332
|
+
<div className="pr-3 pb-2 pl-8 text-xs text-text-faint">
|
|
333
|
+
{t('work.baseMoved', { from: baseMoved.from.slice(0, 7), to: baseMoved.to.slice(0, 7) })}
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
325
336
|
{expanded && children}
|
|
326
337
|
</div>
|
|
327
338
|
);
|
|
@@ -337,12 +348,14 @@ export function ChangedFilesRow({
|
|
|
337
348
|
tools,
|
|
338
349
|
diff,
|
|
339
350
|
checkpoint,
|
|
351
|
+
baseMoved,
|
|
340
352
|
chatId,
|
|
341
353
|
turnId
|
|
342
354
|
}: {
|
|
343
355
|
tools: ChatToolItem[];
|
|
344
356
|
diff: ChatCheckpointDiff | null;
|
|
345
357
|
checkpoint: boolean;
|
|
358
|
+
baseMoved?: BaseMoved | undefined;
|
|
346
359
|
chatId: string;
|
|
347
360
|
turnId: string;
|
|
348
361
|
}) {
|
|
@@ -375,6 +388,7 @@ export function ChangedFilesRow({
|
|
|
375
388
|
return (
|
|
376
389
|
<ChangedFilesCard
|
|
377
390
|
revealNonce={revealNonce}
|
|
391
|
+
baseMoved={baseMoved}
|
|
378
392
|
count={files.length}
|
|
379
393
|
truncated={checkpointDiff.truncated}
|
|
380
394
|
added={files.reduce((sum, file) => sum + file.added, 0)}
|
package/src/host.ts
CHANGED
|
@@ -279,6 +279,8 @@ export interface ChatHost {
|
|
|
279
279
|
useLoginBlocked(scopeId: string): string | null;
|
|
280
280
|
/* The name and the mark of a project of the host, for the usage per project; null draws the folder's own. */
|
|
281
281
|
useProjectLook(scopeId: string, projectId: string | null): { name: string; mark: ReactNode } | null;
|
|
282
|
+
/* What a person calls the machine the scope's chats run on, or null when it has no name worth saying. */
|
|
283
|
+
useMachineLabel(scopeId: string): string | null;
|
|
282
284
|
/* Where another chat of the app stands, for a fork's way back and a note from a fork. */
|
|
283
285
|
useChatPlace(chatId: string): ChatPlace;
|
|
284
286
|
/* What a chat may read besides its folder, named under an empty thread. */
|
|
@@ -345,6 +347,7 @@ const DEFAULT_HOST: ChatHost = {
|
|
|
345
347
|
openLogin: null,
|
|
346
348
|
useLoginBlocked: () => null,
|
|
347
349
|
useProjectLook: () => null,
|
|
350
|
+
useMachineLabel: () => null,
|
|
348
351
|
useChatPlace: () => NOWHERE,
|
|
349
352
|
useContextSources: () => NO_SOURCES,
|
|
350
353
|
useHidesFolder: () => false,
|