@adecore/agents-react 0.20.0 → 0.21.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.
- package/dist/chat/chat-client.d.ts +1 -0
- package/dist/chat/logic/generated-image.d.ts +27 -0
- package/dist/chat/logic/generated-image.js +32 -0
- package/dist/chat/logic/timeline-target.d.ts +19 -1
- package/dist/chat/logic/timeline-target.js +49 -3
- package/dist/chat/logic/timeline.js +25 -17
- package/dist/chat/subagent-conversation.d.ts +2 -1
- package/dist/chat/subagent-conversation.js +7 -1
- package/dist/chat/timeline-flash.d.ts +3 -1
- package/dist/chat/timeline-flash.js +2 -2
- package/dist/chat/ui/CodeBlock.d.ts +3 -1
- package/dist/chat/ui/CodeBlock.js +38 -7
- package/dist/chat/ui/Composer.js +13 -3
- package/dist/chat/ui/ImageView.d.ts +8 -2
- package/dist/chat/ui/ImageView.js +12 -6
- package/dist/chat/ui/Markdown.d.ts +7 -1
- package/dist/chat/ui/Markdown.js +80 -23
- package/dist/chat/ui/MessageActions.js +6 -7
- package/dist/chat/ui/SubagentTimeline.js +7 -3
- package/dist/chat/ui/Timeline.js +68 -8
- package/dist/chat/ui/TimelineMenu.js +7 -3
- package/dist/chat/ui/file-links.d.ts +2 -1
- package/dist/chat/ui/file-links.js +8 -3
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
- package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
- package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
- package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
- package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
- package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
- package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
- package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/registry.js +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
- package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
- package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
- package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/tones.js +31 -0
- package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
- package/dist/chat/ui/reply-context.d.ts +8 -0
- package/dist/chat/ui/reply-context.js +1 -0
- package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
- package/dist/chat/ui/rows/MessageRows.js +27 -7
- package/dist/chat/ui/rows/Rows.js +4 -2
- package/dist/chat/ui/rows/SubagentRow.js +5 -0
- package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -3
- package/dist/host.d.ts +25 -3
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +89 -1
- package/dist/locales/nl/agent-chat.json +89 -1
- package/dist/state/chats.js +9 -0
- package/dist/theme.css +38 -2
- package/package.json +133 -7
- package/src/chat/chat-client.ts +1 -0
- package/src/chat/logic/generated-image.ts +48 -0
- package/src/chat/logic/timeline-target.ts +69 -4
- package/src/chat/logic/timeline.ts +28 -17
- package/src/chat/subagent-conversation.ts +13 -2
- package/src/chat/timeline-flash.ts +5 -3
- package/src/chat/ui/CodeBlock.tsx +70 -21
- package/src/chat/ui/Composer.tsx +22 -5
- package/src/chat/ui/ImageView.tsx +51 -8
- package/src/chat/ui/Markdown.tsx +123 -26
- package/src/chat/ui/MessageActions.tsx +15 -8
- package/src/chat/ui/SubagentTimeline.tsx +26 -1
- package/src/chat/ui/Timeline.tsx +98 -22
- package/src/chat/ui/TimelineMenu.tsx +6 -3
- package/src/chat/ui/file-links.ts +9 -3
- package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
- package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
- package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
- package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
- package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
- package/src/chat/ui/intelligent-ui/registry.ts +52 -0
- package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
- package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
- package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
- package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
- package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
- package/src/chat/ui/intelligent-ui/tones.ts +37 -0
- package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
- package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
- package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
- package/src/chat/ui/reply-context.ts +7 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
- package/src/chat/ui/rows/MessageRows.tsx +69 -7
- package/src/chat/ui/rows/Rows.tsx +4 -2
- package/src/chat/ui/rows/SubagentRow.tsx +5 -0
- package/src/chat/ui/rows/WorkRows.tsx +20 -5
- package/src/host.ts +46 -3
- package/src/locales/en/agent-chat.json +89 -1
- package/src/locales/nl/agent-chat.json +89 -1
- package/src/state/chats.ts +9 -0
- package/src/theme.css +38 -2
package/dist/chat/ui/Markdown.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { memo, useMemo } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, memo, useContext, useMemo } from 'react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { Zap } from 'lucide-react';
|
|
5
5
|
import ReactMarkdown, {} from 'react-markdown';
|
|
@@ -12,8 +12,43 @@ import { splitMarkdownBlocks } from './markdown-blocks.js';
|
|
|
12
12
|
import { rehypeChips } from './rehype-chips.js';
|
|
13
13
|
import { rehypeFadeWords } from './rehype-fade.js';
|
|
14
14
|
import { remarkHtmlAsText } from './remark-html-as-text.js';
|
|
15
|
-
import {
|
|
15
|
+
import { chatHost } from '../../host.js';
|
|
16
|
+
import { openFileLink, useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget } from './file-links.js';
|
|
16
17
|
import { FileIcon, Icon } from '@adecore/ui';
|
|
18
|
+
const ShellCodeBlockReplyContext = createContext(null);
|
|
19
|
+
const BlockCodeContext = createContext(false);
|
|
20
|
+
/* Only root children qualify; Markdown's parser decides whether a fence belongs to a list or quote. */
|
|
21
|
+
function remarkShellCodeBlocks() {
|
|
22
|
+
return (tree, file) => {
|
|
23
|
+
for (const node of tree.children ?? []) {
|
|
24
|
+
if (node.type !== 'code') {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const start = node.position?.start.offset;
|
|
28
|
+
const end = node.position?.end.offset;
|
|
29
|
+
if (start === undefined || end === undefined) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const raw = String(file.value).slice(start, end);
|
|
33
|
+
const opening = /^ {0,3}(`{3,}|~{3,})[ \t]*(sh|bash|zsh)[ \t]*\r?\n/.exec(raw);
|
|
34
|
+
if (opening === null) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const body = raw.slice(opening[0].length);
|
|
38
|
+
const closing = /(?:^|\r?\n) {0,3}(`{3,}|~{3,})[ \t]*$/.exec(body);
|
|
39
|
+
const closed = closing !== null && closing[1][0] === opening[1][0] && closing[1].length >= opening[1].length;
|
|
40
|
+
node.data = {
|
|
41
|
+
...node.data,
|
|
42
|
+
hProperties: {
|
|
43
|
+
...node.data?.hProperties,
|
|
44
|
+
'data-shell-language': opening[2],
|
|
45
|
+
'data-shell-code': closed ? body.slice(0, closing.index) : body,
|
|
46
|
+
'data-shell-closed': closed
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
17
52
|
function languageOf(className) {
|
|
18
53
|
return /language-([\w-]+)/.exec(className ?? '')?.[1] ?? 'text';
|
|
19
54
|
}
|
|
@@ -26,7 +61,8 @@ const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code
|
|
|
26
61
|
*/
|
|
27
62
|
function FileLink({ target, className, children }) {
|
|
28
63
|
const cwd = useFileLinkCwd();
|
|
29
|
-
|
|
64
|
+
const scopeId = useFileLinkScopeId();
|
|
65
|
+
return (_jsx("button", { type: "button", className: clsx('cursor-pointer select-text', className), "data-file-path": target.path, "data-file-line": target.line, "data-file-column": target.column, "data-file-end-line": target.endLine, "data-file-directory": target.directory, "data-file-cwd": cwd ?? '', "data-file-scope-id": scopeId ?? '', onClick: () => openFileLink(cwd, target, scopeId), children: children }));
|
|
30
66
|
}
|
|
31
67
|
/* Inline code, which is a file reference often enough that it is worth asking every time. */
|
|
32
68
|
function InlineCode({ text }) {
|
|
@@ -55,18 +91,19 @@ function MarkdownLink({ href, children }) {
|
|
|
55
91
|
}
|
|
56
92
|
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
57
93
|
}
|
|
94
|
+
function MarkdownCode({ className, children, plain = false }) {
|
|
95
|
+
const block = useContext(BlockCodeContext);
|
|
96
|
+
const text = String(children ?? '');
|
|
97
|
+
if (block) {
|
|
98
|
+
return _jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className) });
|
|
99
|
+
}
|
|
100
|
+
return plain ? _jsx("code", { className: INLINE_CODE, children: text }) : _jsx(InlineCode, { text: text });
|
|
101
|
+
}
|
|
58
102
|
const components = {
|
|
59
103
|
pre({ children }) {
|
|
60
|
-
return _jsx(
|
|
61
|
-
},
|
|
62
|
-
code({ className, children }) {
|
|
63
|
-
const text = String(children ?? '');
|
|
64
|
-
// Fenced blocks end with a newline and carry a language; anything else is inline.
|
|
65
|
-
if (className?.startsWith('language-') || text.includes('\n')) {
|
|
66
|
-
return _jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className) });
|
|
67
|
-
}
|
|
68
|
-
return _jsx(InlineCode, { text: text });
|
|
104
|
+
return _jsx(BlockCodeContext.Provider, { value: true, children: children });
|
|
69
105
|
},
|
|
106
|
+
code: MarkdownCode,
|
|
70
107
|
a({ href, children }) {
|
|
71
108
|
return _jsx(MarkdownLink, { href: href, children: children });
|
|
72
109
|
},
|
|
@@ -79,11 +116,7 @@ const components = {
|
|
|
79
116
|
const plainComponents = {
|
|
80
117
|
...components,
|
|
81
118
|
code({ className, children }) {
|
|
82
|
-
|
|
83
|
-
if (className?.startsWith('language-') || text.includes('\n')) {
|
|
84
|
-
return _jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className) });
|
|
85
|
-
}
|
|
86
|
-
return _jsx("code", { className: INLINE_CODE, children: text });
|
|
119
|
+
return (_jsx(MarkdownCode, { className: className, plain: true, children: children }));
|
|
87
120
|
},
|
|
88
121
|
a({ href, children }) {
|
|
89
122
|
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
@@ -100,6 +133,7 @@ function replyHeading(level) {
|
|
|
100
133
|
}
|
|
101
134
|
const replyComponents = {
|
|
102
135
|
...components,
|
|
136
|
+
code: ReplyCode,
|
|
103
137
|
h1: replyHeading(1),
|
|
104
138
|
h2: replyHeading(2),
|
|
105
139
|
h3: replyHeading(3),
|
|
@@ -107,12 +141,34 @@ const replyComponents = {
|
|
|
107
141
|
h5: replyHeading(5),
|
|
108
142
|
h6: replyHeading(6)
|
|
109
143
|
};
|
|
144
|
+
function ReplyCode({ className, children, node }) {
|
|
145
|
+
const reply = useContext(ShellCodeBlockReplyContext);
|
|
146
|
+
const block = useContext(BlockCodeContext);
|
|
147
|
+
const properties = node?.properties;
|
|
148
|
+
const language = properties?.['data-shell-language'];
|
|
149
|
+
const code = properties?.['data-shell-code'];
|
|
150
|
+
const context = reply !== null && (language === 'sh' || language === 'bash' || language === 'zsh') && typeof code === 'string'
|
|
151
|
+
? {
|
|
152
|
+
scopeId: reply.scopeId,
|
|
153
|
+
chatId: reply.chatId,
|
|
154
|
+
itemId: reply.itemId,
|
|
155
|
+
language,
|
|
156
|
+
code,
|
|
157
|
+
complete: !reply.streaming && properties?.['data-shell-closed'] === true
|
|
158
|
+
}
|
|
159
|
+
: null;
|
|
160
|
+
const text = String(children ?? '');
|
|
161
|
+
if (block) {
|
|
162
|
+
return (_jsx(CodeBlock, { code: text.replace(/\n$/, ''), lang: languageOf(className), actions: context !== null ? chatHost().renderShellCodeBlock?.(context) : undefined }));
|
|
163
|
+
}
|
|
164
|
+
return _jsx(InlineCode, { text: text });
|
|
165
|
+
}
|
|
110
166
|
/* A picked file or skill in a sent message. The glyph stands in for the sigil the text still carries. */
|
|
111
167
|
function Chip({ kind, value }) {
|
|
112
168
|
if (kind === 'skill') {
|
|
113
169
|
return (_jsxs("span", { className: clsx(CHIP_IN_MESSAGE, SKILL_TONE), children: [_jsx(Icon, { icon: Zap, size: 14, className: "shrink-0 opacity-85" }), _jsx("span", { className: "truncate", children: value })] }));
|
|
114
170
|
}
|
|
115
|
-
return (_jsxs("span", { className: clsx(CHIP_IN_MESSAGE, MENTION_TONE), "data-file-path": value, children: [_jsx(FileIcon, { path: value, size: 14 }), _jsx("span", { className: "truncate", children: value })] }));
|
|
171
|
+
return (_jsxs("span", { className: clsx(CHIP_IN_MESSAGE, MENTION_TONE), "data-file-path": value, "data-file-directory": /[\\/]$/.test(value), children: [_jsx(FileIcon, { path: value, size: 14 }), _jsx("span", { className: "truncate", children: value })] }));
|
|
116
172
|
}
|
|
117
173
|
const messageComponents = {
|
|
118
174
|
...replyComponents,
|
|
@@ -125,14 +181,15 @@ const messageComponents = {
|
|
|
125
181
|
};
|
|
126
182
|
// Module constants, so a render never hands react-markdown a fresh array and makes it parse again.
|
|
127
183
|
const PLUGINS = [remarkGfm];
|
|
184
|
+
const REPLY_PLUGINS = [remarkGfm, remarkShellCodeBlocks];
|
|
128
185
|
const PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
|
|
129
186
|
const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
|
|
130
187
|
const FADE_PLUGINS = [rehypeFadeWords];
|
|
131
188
|
const NO_PLUGINS = [];
|
|
132
189
|
/* One block of a reply. It renders no element of its own, so prose still sees the paragraphs as
|
|
133
190
|
direct children and keeps its first and last margins. */
|
|
134
|
-
const ReplyBlock = memo(function ReplyBlock({ text, fade, open }) {
|
|
135
|
-
return (_jsx(CodeStreamingContext.Provider, { value: open, children: _jsx(ReactMarkdown, { remarkPlugins:
|
|
191
|
+
const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }) {
|
|
192
|
+
return (_jsx(CodeStreamingContext.Provider, { value: open, children: _jsx(ShellCodeBlockReplyContext.Provider, { value: reply ?? null, children: _jsx(ReactMarkdown, { remarkPlugins: REPLY_PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, components: replyComponents, children: text }) }) }));
|
|
136
193
|
});
|
|
137
194
|
/*
|
|
138
195
|
* A reply in a chat thread, parsed a block at a time so a delta only parses the block that grows.
|
|
@@ -140,9 +197,9 @@ const ReplyBlock = memo(function ReplyBlock({ text, fade, open }) {
|
|
|
140
197
|
* line at a time as it grows. `arriving` fades in each block as it is added instead, for a reply
|
|
141
198
|
* that is shown a block at a time.
|
|
142
199
|
*/
|
|
143
|
-
export const ReplyMarkdown = memo(function ReplyMarkdown({ text, streaming, arriving = false }) {
|
|
200
|
+
export const ReplyMarkdown = memo(function ReplyMarkdown({ text, streaming, arriving = false, reply }) {
|
|
144
201
|
const blocks = useMemo(() => splitMarkdownBlocks(text), [text]);
|
|
145
|
-
return (_jsx("div", { className: "chat-markdown prose prose-sm max-w-none text-sm", "data-arriving": arriving || undefined, children: blocks.map((block, index) => (_jsx(ReplyBlock, { text: block.text, fade: streaming, open: streaming && block.openFence }, index))) }));
|
|
202
|
+
return (_jsx("div", { className: "chat-markdown prose prose-sm max-w-none text-sm", "data-arriving": arriving || undefined, children: blocks.map((block, index) => (_jsx(ReplyBlock, { text: block.text, fade: streaming, open: streaming && block.openFence, reply: reply }, index))) }));
|
|
146
203
|
});
|
|
147
204
|
/*
|
|
148
205
|
* A message a person sent. Enter meant a line break, a tag typed without backticks stays the text
|
|
@@ -9,15 +9,14 @@ import { markdownOf, messageTextOf } from '../logic/timeline-copy.js';
|
|
|
9
9
|
import { chatHost } from '../../host.js';
|
|
10
10
|
import { useChatScope } from '../../scope.js';
|
|
11
11
|
import { useChatRow, useChats } from '../../state/chats.js';
|
|
12
|
-
import { ButtonGroup, copyText, IconButton } from '@adecore/ui';
|
|
12
|
+
import { ButtonGroup, copyText, IconButton, Tooltip } from '@adecore/ui';
|
|
13
|
+
import { formatClock, formatDateTime, useFormatLocale } from '@adecore/ui/format';
|
|
13
14
|
const COPIED_MS = 1500;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* `group/message`. The row always takes its height, so the virtualizer never measures it again on a
|
|
17
|
-
* hover. Like the strip's card, it leaves out an action the message cannot take right now.
|
|
18
|
-
*/
|
|
15
|
+
const TIMESTAMP_FORMAT = { dateStyle: 'full', timeStyle: 'medium' };
|
|
16
|
+
/* Hidden actions keep their height, so hovering never forces the virtualizer to remeasure. */
|
|
19
17
|
export function MessageActions({ chatId, row }) {
|
|
20
18
|
const { t } = useTranslation('agent-chat');
|
|
19
|
+
useFormatLocale();
|
|
21
20
|
const scope = useChatScope();
|
|
22
21
|
const { fork } = chatHost();
|
|
23
22
|
const turnId = row.item.turnId;
|
|
@@ -49,5 +48,5 @@ export function MessageActions({ chatId, row }) {
|
|
|
49
48
|
setCopied(true);
|
|
50
49
|
}
|
|
51
50
|
};
|
|
52
|
-
return (
|
|
51
|
+
return (_jsxs("div", { role: "toolbar", "aria-label": t('timeline.actions.label'), className: clsx('flex h-7 items-center gap-2 opacity-0 transition-opacity group-has-focus-visible/message:opacity-100 group-hover/message:opacity-100', row.kind === 'user' && 'mt-1.5 justify-end'), children: [!streaming && (_jsxs(ButtonGroup, { render: _jsx("span", {}), children: [bookmark === null && (_jsx(IconButton, { icon: Bookmark, size: "sm", label: t('bookmarks.add'), onClick: () => void placeBookmark(scope, chatId, row.id) })), canFork && turnId !== null && fork !== null && (_jsx(IconButton, { icon: GitFork, size: "sm", label: t('timeline.menu.forkFromHere'), onClick: () => fork(chatId, turnId) })), hasText && (_jsx(IconButton, { icon: copied ? Check : Copy, size: "sm", label: copied ? t('timeline.actions.copied') : t('timeline.menu.copyMessage'), onClick: (e) => copy(e.shiftKey) }))] })), _jsx(Tooltip, { label: formatDateTime(row.item.createdAt, TIMESTAMP_FORMAT), children: _jsx("time", { dateTime: new Date(row.item.createdAt).toISOString(), className: clsx('shrink-0 text-xs text-text-faint tabular-nums select-none', row.kind === 'assistant' && 'order-first'), children: formatClock(row.item.createdAt) }) })] }));
|
|
53
52
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { Bot, LoaderCircle } from 'lucide-react';
|
|
5
5
|
import { deriveTimelineRows } from '../logic/timeline.js';
|
|
@@ -10,6 +10,8 @@ import { EMPTY_TARGET, readTimelineTarget } from '../logic/timeline-target.js';
|
|
|
10
10
|
import { openBelow, useSubagentTrail } from '../subagent-view.js';
|
|
11
11
|
import { Row } from './rows/Rows.js';
|
|
12
12
|
import { ReplyContext } from './reply-context.js';
|
|
13
|
+
import { PromptComposer } from './PromptComposer.js';
|
|
14
|
+
import { useProviders } from '../../state/providers.js';
|
|
13
15
|
import { SubagentInfo } from './SubagentInfo.js';
|
|
14
16
|
import { TimelineMenuPopup } from './TimelineMenu.js';
|
|
15
17
|
import { FOLLOW_THRESHOLD_PX, rowRhythm } from './rows/row-rhythm.js';
|
|
@@ -26,8 +28,10 @@ const NO_TURNS = new Set();
|
|
|
26
28
|
export function SubagentTimeline({ chatId, toolUseId }) {
|
|
27
29
|
const { t } = useTranslation('agent-chat');
|
|
28
30
|
const scope = useChatScope();
|
|
31
|
+
const connected = useSyncExternalStore(useCallback((changed) => scope.transport.subscribeStatus(changed), [scope]), useCallback(() => scope.transport.status === 'open', [scope]), () => false);
|
|
29
32
|
const { trail, show } = useSubagentTrail(chatId);
|
|
30
33
|
const [state, setState] = useState(INITIAL_CONVERSATION);
|
|
34
|
+
const denyReason = useProviders((row) => row.providers.find((provider) => provider.kind === state.context?.provider)?.capabilities.denyReason === true);
|
|
31
35
|
const controller = useRef(null);
|
|
32
36
|
const scrollRef = useRef(null);
|
|
33
37
|
const threadRef = useRef(null);
|
|
@@ -94,7 +98,7 @@ export function SubagentTimeline({ chatId, toolUseId }) {
|
|
|
94
98
|
const openChild = (step) => {
|
|
95
99
|
show(openBelow(trail, step));
|
|
96
100
|
};
|
|
97
|
-
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsx(SubagentInfo, { chatId: chatId, toolUseId: toolUseId }), state.status === 'loading' && _jsx("div", { className: "chat-column-content px-4 pt-4 text-xs text-text-faint", children: t('subagents.loading') }), state.status === 'failed' && (_jsx(EmptyState, { icon: Bot, children: state.unsupported ? t('subagents.unsupported') : (state.error ?? t('subagents.unreadable')) })), state.status === 'ready' && (_jsx(FileLinkContext.Provider, { value: state.context?.cwd ?? cwd, children: _jsx(ReplyContext.Provider, { value: state.context ?? { provider: state.source === 'codex-thread' ? 'codex' : 'claude' }, children: _jsx("div", { ref: scrollRef, className: "chat-thread relative min-h-0 grow overflow-auto px-4 pt-4 pb-3",
|
|
101
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsx(SubagentInfo, { chatId: chatId, toolUseId: toolUseId }), state.status === 'loading' && _jsx("div", { className: "chat-column-content px-4 pt-4 text-xs text-text-faint", children: t('subagents.loading') }), state.status === 'failed' && (_jsx(EmptyState, { icon: Bot, children: state.unsupported ? t('subagents.unsupported') : (state.error ?? t('subagents.unreadable')) })), state.status === 'ready' && (_jsx(FileLinkContext.Provider, { value: state.context?.cwd ?? cwd, children: _jsx(ReplyContext.Provider, { value: state.context ?? { provider: state.source === 'codex-thread' ? 'codex' : 'claude' }, children: _jsx("div", { ref: scrollRef, "data-file-cwd": state.context?.cwd ?? cwd ?? '', "data-file-scope-id": scope.id, className: "chat-thread relative min-h-0 grow overflow-auto px-4 pt-4 pb-3",
|
|
98
102
|
// The thread keeps its reader in place itself when a page goes in above.
|
|
99
103
|
style: { overflowAnchor: 'none' }, onScroll: (event) => {
|
|
100
104
|
const element = event.currentTarget;
|
|
@@ -103,5 +107,5 @@ export function SubagentTimeline({ chatId, toolUseId }) {
|
|
|
103
107
|
}, children: _jsxs(ContextMenu.Root, { children: [_jsxs(ContextMenu.Trigger, { ref: threadRef, className: "chat-column-content", onContextMenu: (event) => setTarget(readTimelineTarget(event.target, threadRef.current, rows)), children: [state.loadingEarlier && (_jsxs("div", { className: "pointer-events-none sticky top-0 z-10 flex h-0 justify-center", role: "status", children: [_jsx(Icon, { icon: LoaderCircle, size: 16, className: "animate-spin text-text-faint" }), _jsx("span", { className: "sr-only", children: t('timeline.loadingEarlier') })] })), rows.length === 0 && !state.live && _jsx(EmptyState, { icon: Bot, children: t('rows.subagent.nothingYet') }), rows.map((row, index) => {
|
|
104
108
|
const previous = index > 0 ? rows[index - 1] : null;
|
|
105
109
|
return (_jsx("div", { "data-item-id": row.id, className: rowRhythm(row, previous), children: _jsx(Row, { row: row, chatId: chatId, toggleGroup: groups.toggle, toggleTurn: () => undefined, toggleSubagent: subagents.toggle, openSubagent: () => undefined, openConversation: openChild }) }, row.id));
|
|
106
|
-
}), state.live && _jsx("div", { className: "chat-live-text pt-1 text-xs", children: t('subagents.working') })] }), _jsx(TimelineMenuPopup, { target: target, thread: threadRef })] }) }) }) }))] }));
|
|
110
|
+
}), state.live && _jsx("div", { className: "chat-live-text pt-1 text-xs", children: t('subagents.working') })] }), _jsx(TimelineMenuPopup, { target: target, thread: threadRef })] }) }) }) })), state.context?.chatId !== undefined && state.pending.length > 0 && (_jsx("div", { className: "chat-column-content shrink-0", children: _jsx(PromptComposer, { chatId: state.context.chatId, pending: state.pending, focused: false, disabled: !connected, hasDraft: false, denyReason: denyReason, children: null }, state.context.chatId) }))] }));
|
|
107
111
|
}
|
package/dist/chat/ui/Timeline.js
CHANGED
|
@@ -12,8 +12,9 @@ import { openFromMain, useSubagentTrail } from '../subagent-view.js';
|
|
|
12
12
|
import { registerItemJumper, registerMessageStepper, registerTimeline, registerTurnJumper, setTimelineAtEnd, wantsEarlier } from '../timeline-scroll.js';
|
|
13
13
|
import { useTimelineFlash } from '../timeline-flash.js';
|
|
14
14
|
import { SCRUBBER_MIN_TICKS, STRIP_INSET_PX, STRIP_WIDTH_PX, messagesInView, stepMessage, threadPaddingLeft, STRIP_CLEARANCE_PX, tickOfRow, ticksOf, ticksWithHits } from '../logic/scrubber.js';
|
|
15
|
-
import { EMPTY_TARGET, readTimelineTarget, withCurrentText } from '../logic/timeline-target.js';
|
|
15
|
+
import { EMPTY_TARGET, readTimelineTarget, revealUiReplyBlock, uiReplyJump, withCurrentText } from '../logic/timeline-target.js';
|
|
16
16
|
import { forkRefusal } from '../logic/fork.js';
|
|
17
|
+
import { UiReplyNavigationContext } from './reply-context.js';
|
|
17
18
|
import { FindRevealContext } from './find-reveal.js';
|
|
18
19
|
import { BookmarkMarker } from './BookmarkMarker.js';
|
|
19
20
|
import { MessageActions } from './MessageActions.js';
|
|
@@ -142,9 +143,19 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
142
143
|
const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
|
|
143
144
|
// A message a jump is on its way to; it may first have to open the turn its reply folded into.
|
|
144
145
|
const [seeking, setSeeking] = useState(null);
|
|
146
|
+
const [landing, setLanding] = useState(null);
|
|
145
147
|
// A turn a jump is on its way to, which waits for the page its messages are on to come in.
|
|
146
148
|
const [revealing, setRevealing] = useState(null);
|
|
147
149
|
const flash = useTimelineFlash((s) => s.target);
|
|
150
|
+
const revealBlock = useCallback((block) => {
|
|
151
|
+
setLanding(null);
|
|
152
|
+
setSeeking({ itemId: block.itemId, block, key: keyOf(chatId) });
|
|
153
|
+
}, [keyOf, chatId]);
|
|
154
|
+
const uiNavigation = useMemo(() => ({
|
|
155
|
+
chatId,
|
|
156
|
+
reveal: revealBlock,
|
|
157
|
+
flash: flash?.key === keyOf(chatId) && flash.block ? { itemId: flash.rowId, ...flash.block, nonce: flash.nonce } : null
|
|
158
|
+
}), [revealBlock, flash, keyOf, chatId]);
|
|
148
159
|
// Whether the thread's text clears the strip depends on the width of the view, which only the thread's own frame tells.
|
|
149
160
|
useEffect(() => {
|
|
150
161
|
const element = frameRef.current;
|
|
@@ -299,7 +310,8 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
299
310
|
return;
|
|
300
311
|
}
|
|
301
312
|
if (tick.bookmark !== null) {
|
|
302
|
-
|
|
313
|
+
setLanding(null);
|
|
314
|
+
setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
|
|
303
315
|
return;
|
|
304
316
|
}
|
|
305
317
|
followRef.current = false;
|
|
@@ -309,14 +321,36 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
309
321
|
if (seeking === null) {
|
|
310
322
|
return;
|
|
311
323
|
}
|
|
312
|
-
|
|
324
|
+
if (seeking.key !== keyOf(chatId)) {
|
|
325
|
+
setSeeking(null);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (seeking.block) {
|
|
329
|
+
const jump = uiReplyJump(seeking.block, rows, items, turns.ids, cursor !== null);
|
|
330
|
+
if (jump.kind === 'earlier') {
|
|
331
|
+
loadEarlier();
|
|
332
|
+
}
|
|
333
|
+
else if (jump.kind === 'turn') {
|
|
334
|
+
turns.add(jump.turnId);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
setSeeking(null);
|
|
338
|
+
if (jump.kind === 'row') {
|
|
339
|
+
followRef.current = false;
|
|
340
|
+
virtualizer.scrollToIndex(jump.index, { align: 'start' });
|
|
341
|
+
setLanding({ target: seeking.block, key: seeking.key });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const index = rows.findIndex((row) => row.id === seeking.itemId);
|
|
313
347
|
if (index !== -1) {
|
|
314
348
|
setSeeking(null);
|
|
315
349
|
followRef.current = false;
|
|
316
350
|
virtualizer.scrollToIndex(index, { align: 'start' });
|
|
317
351
|
return;
|
|
318
352
|
}
|
|
319
|
-
const item = items?.[seeking];
|
|
353
|
+
const item = items?.[seeking.itemId];
|
|
320
354
|
if (item === undefined && cursor !== null) {
|
|
321
355
|
loadEarlier();
|
|
322
356
|
return;
|
|
@@ -327,8 +361,34 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
327
361
|
return;
|
|
328
362
|
}
|
|
329
363
|
setSeeking(null);
|
|
330
|
-
}, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier]);
|
|
331
|
-
useEffect(() => registerItemJumper(keyOf(chatId),
|
|
364
|
+
}, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
|
|
365
|
+
useEffect(() => registerItemJumper(keyOf(chatId), (itemId) => {
|
|
366
|
+
setLanding(null);
|
|
367
|
+
setSeeking({ itemId, key: keyOf(chatId) });
|
|
368
|
+
}), [keyOf, chatId]);
|
|
369
|
+
useLayoutEffect(() => {
|
|
370
|
+
if (!landing || landing.key !== keyOf(chatId)) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
let frame = 0;
|
|
374
|
+
let attempts = 0;
|
|
375
|
+
const reveal = () => {
|
|
376
|
+
const scroller = scrollRef.current;
|
|
377
|
+
if (scroller && revealUiReplyBlock(scroller, landing.target)) {
|
|
378
|
+
useTimelineFlash.getState().flash(keyOf(chatId), landing.target.itemId, false, landing.target);
|
|
379
|
+
setLanding(null);
|
|
380
|
+
}
|
|
381
|
+
else if (++attempts < 30) {
|
|
382
|
+
// Virtual rows mount after scrollToIndex updates the visible range.
|
|
383
|
+
frame = requestAnimationFrame(reveal);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
setLanding(null);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
frame = requestAnimationFrame(reveal);
|
|
390
|
+
return () => cancelAnimationFrame(frame);
|
|
391
|
+
}, [landing, keyOf, chatId]);
|
|
332
392
|
useLayoutEffect(() => {
|
|
333
393
|
if (revealing === null) {
|
|
334
394
|
return;
|
|
@@ -395,7 +455,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
395
455
|
followRef.current = false;
|
|
396
456
|
virtualizer.scrollToIndex(found.index, { align: 'start' });
|
|
397
457
|
};
|
|
398
|
-
return (_jsx(FileLinkContext.Provider, { value: info?.cwd ?? null, children: _jsxs("div", { ref: frameRef, className: "relative flex min-h-0 grow flex-col", children: [searching && chatFind.bar, _jsxs(ContextMenu.Root, { children: [_jsx("div", { ref: scrollRef,
|
|
458
|
+
return (_jsx(FileLinkContext.Provider, { value: info?.cwd ?? null, children: _jsxs("div", { ref: frameRef, className: "relative flex min-h-0 grow flex-col", "data-file-cwd": info?.cwd ?? '', "data-file-scope-id": scope.id, children: [searching && chatFind.bar, _jsxs(ContextMenu.Root, { children: [_jsx("div", { ref: scrollRef,
|
|
399
459
|
// No CSS scroll-padding here: Chromium counts the sticky composer's caret as hidden behind
|
|
400
460
|
// it and scrolls the thread toward the end on every keystroke.
|
|
401
461
|
className: "chat-scroll min-h-0 grow overflow-auto",
|
|
@@ -424,7 +484,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
424
484
|
const row = rows[virtualRow.index];
|
|
425
485
|
const previous = virtualRow.index > 0 ? rows[virtualRow.index - 1] : null;
|
|
426
486
|
const header = replyHeader(row, previous);
|
|
427
|
-
return (_jsxs(RowContainer, { row: row, previous: previous, index: virtualRow.index, top: virtualRow.start, ref: virtualizer.measureElement, children: [flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && (_jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce)), header !== null && (_jsx("div", { className: "chat-column-content w-full", children: _jsx(ReplyHeader, { chatId: chatId, at: header.at }) })), _jsx(FindRevealContext.Provider, { value: chatFind.reveal, children: _jsx(MarkableRow, { row: row, chatId: chatId, bookmark: marks.get(row.id) ?? null, children: _jsx(Row, { row: row, chatId: chatId, toggleGroup: groups.toggle, toggleTurn: turns.toggle, toggleSubagent: subagents.toggle, openSubagent: openSubagent, openConversation: openConversation }) }) })] }, row.id));
|
|
487
|
+
return (_jsxs(RowContainer, { row: row, previous: previous, index: virtualRow.index, top: virtualRow.start, ref: virtualizer.measureElement, children: [flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && !flash.block && (_jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce)), header !== null && (_jsx("div", { className: "chat-column-content w-full", children: _jsx(ReplyHeader, { chatId: chatId, at: header.at }) })), _jsx(UiReplyNavigationContext.Provider, { value: uiNavigation, children: _jsx(FindRevealContext.Provider, { value: chatFind.reveal, children: _jsx(MarkableRow, { row: row, chatId: chatId, bookmark: marks.get(row.id) ?? null, children: _jsx(Row, { row: row, chatId: chatId, toggleGroup: groups.toggle, toggleTurn: turns.toggle, toggleSubagent: subagents.toggle, openSubagent: openSubagent, openConversation: openConversation }) }) }) })] }, row.id));
|
|
428
488
|
}) })) }), _jsx(QuoteButton, { thread: threadRef, frame: contentRef, taker: quoteTakerRef }), _jsx("div", { ref: composerRef,
|
|
429
489
|
// The card is translucent so the thread shows through it, but the pb-3 strip under it must stay opaque.
|
|
430
490
|
className: clsx('relative z-10 shrink-0', composer && 'px-3 pb-3 pt-3 bg-[linear-gradient(to_bottom,transparent_calc(100%-12px),var(--surface)_calc(100%-12px))]'), style: { position: stickyComposer ? 'sticky' : 'relative', bottom: 0 }, children: _jsx(QuoteTakerContext.Provider, { value: registerQuoteTaker, children: composer }) })] }) }), _jsx(TimelineMenuPopup, { target: target, thread: threadRef, chatId: onMainAgent ? chatId : null })] }), drawsVisuals && _jsx(VisualDialogs, { chatId: chatId }), loadingEarlier && !overlay && (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center", role: "status", children: [_jsx(Icon, { icon: LoaderCircle, size: 16, className: "animate-spin text-text-faint" }), _jsx("span", { className: "sr-only", children: t('timeline.loadingEarlier') })] })), showsScrubber && !overlay && (_jsx("div", { className: "absolute top-4", style: { left: STRIP_INSET_PX, width: STRIP_WIDTH_PX, bottom: coveredHeight }, children: _jsx(ErrorBoundary, { label: t('timeline.stripFailed'), resetKeys: [ticks.length], children: _jsx(Scrubber, { ticks: ticks, firstInView: inView?.first ?? null, lastInView: inView?.last ?? null, onPick: pick, chat: cardChat, found: found, foundCurrent: foundCurrent }) }) })), overlay && (_jsx("div", { className: "absolute inset-0 z-5 flex flex-col bg-surface", style: { paddingBottom: coveredHeight }, children: overlay }))] }) }));
|
|
@@ -7,7 +7,7 @@ import { markdownOf, messageTextOf } from '../logic/timeline-copy.js';
|
|
|
7
7
|
import { chatHost } from '../../host.js';
|
|
8
8
|
import { useChatScope } from '../../scope.js';
|
|
9
9
|
import { useChatRow } from '../../state/chats.js';
|
|
10
|
-
import { openFileLink, useFileLinkCwd } from './file-links.js';
|
|
10
|
+
import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links.js';
|
|
11
11
|
import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
|
|
12
12
|
/*
|
|
13
13
|
* The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
|
|
@@ -23,10 +23,14 @@ export function TimelineMenuPopup({ target, thread, chatId = null }) {
|
|
|
23
23
|
const markable = chatId !== null && (target.row?.kind === 'user' || target.row?.kind === 'assistant') ? target.row.id : null;
|
|
24
24
|
// A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
|
|
25
25
|
const cwd = useFileLinkCwd();
|
|
26
|
+
const scopeId = useFileLinkScopeId();
|
|
26
27
|
const { fork, fileLinks } = chatHost();
|
|
27
28
|
const openInPreview = () => {
|
|
28
|
-
if (target.
|
|
29
|
-
openFileLink(cwd,
|
|
29
|
+
if (target.file != null) {
|
|
30
|
+
openFileLink(target.file.cwd, target.file.ref, target.file.scopeId);
|
|
31
|
+
}
|
|
32
|
+
else if (target.path !== null) {
|
|
33
|
+
openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false }, scopeId);
|
|
30
34
|
}
|
|
31
35
|
};
|
|
32
36
|
return (_jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { disabled: target.selection === '', onClick: () => copyText(target.selection), children: [_jsx(Icon, { icon: Copy, size: 14 }), " ", t('common.action.copy'), " ", _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), message !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(message), children: [_jsx(Icon, { icon: MessageSquare, size: 14 }), " ", t('timeline.menu.copyMessage')] })), target.code !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(target.code ?? ''), children: [_jsx(Icon, { icon: Braces, size: 14 }), " ", t('timeline.menu.copyCode')] })), markdown !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(markdown), children: [_jsx(Icon, { icon: FileText, size: 14 }), " ", t('timeline.menu.copyMarkdown')] })), chatId !== null && turnId !== null && fork !== null && (_jsx(DisabledReason, { reason: forkBlocked, children: _jsxs(ContextMenu.Item, { disabled: forkBlocked !== null, onClick: () => fork(chatId, turnId), children: [_jsx(Icon, { icon: GitFork, size: 14 }), " ", t('timeline.menu.forkFromHere')] }) })), chatId !== null && markable !== null && _jsx(BookmarkMenuItems, { chatId: chatId, itemId: markable }), _jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: () => selectAllWithin(thread.current), children: [_jsx(Icon, { icon: Scan, size: 14 }), " ", t('common.action.selectAll')] }), target.path !== null && fileLinks !== null && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: openInPreview, children: [_jsx(Icon, { icon: Eye, size: 14 }), " ", t('timeline.menu.openInPreview')] })] }))] }));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FileRef } from '../../host.js';
|
|
2
2
|
export declare const FileLinkContext: import("react").Context<string | null>;
|
|
3
3
|
export declare function useFileLinkCwd(): string | null;
|
|
4
|
+
export declare function useFileLinkScopeId(): string | null;
|
|
4
5
|
export declare function useFileLinkTarget(text: string): FileRef | null;
|
|
5
|
-
export declare function openFileLink(cwd: string | null, ref: FileRef): void;
|
|
6
|
+
export declare function openFileLink(cwd: string | null, ref: FileRef, scopeId?: string | null): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo } from 'react';
|
|
2
2
|
import { chatHost } from '../../host.js';
|
|
3
|
+
import { ChatScopeContext } from '../../scope.js';
|
|
3
4
|
/*
|
|
4
5
|
* The folder a relative reference in rendered text counts from: a chat's own cwd, which is a
|
|
5
6
|
* worktree as often as it is the project, or the folder of the file an app is drawing. Null where
|
|
@@ -9,12 +10,16 @@ export const FileLinkContext = createContext(null);
|
|
|
9
10
|
export function useFileLinkCwd() {
|
|
10
11
|
return useContext(FileLinkContext);
|
|
11
12
|
}
|
|
13
|
+
export function useFileLinkScopeId() {
|
|
14
|
+
return useContext(ChatScopeContext)?.id ?? null;
|
|
15
|
+
}
|
|
12
16
|
/* The reference a piece of text names, only where this spot on screen could open it. */
|
|
13
17
|
export function useFileLinkTarget(text) {
|
|
14
18
|
const cwd = useFileLinkCwd();
|
|
15
|
-
|
|
19
|
+
const scopeId = useFileLinkScopeId();
|
|
20
|
+
return useMemo(() => chatHost().fileLinks?.target(text, cwd, scopeId) ?? null, [text, cwd, scopeId]);
|
|
16
21
|
}
|
|
17
22
|
/* A reference followed, the way the app opens a file or a folder. */
|
|
18
|
-
export function openFileLink(cwd, ref) {
|
|
19
|
-
chatHost().fileLinks?.open(cwd, ref);
|
|
23
|
+
export function openFileLink(cwd, ref, scopeId = null) {
|
|
24
|
+
chatHost().fileLinks?.open(cwd, ref, scopeId);
|
|
20
25
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UiLiveStatus } from './render-context.js';
|
|
2
|
+
export interface UiBlockFix {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface UiBlockFooterProps {
|
|
7
|
+
live?: UiLiveStatus | null;
|
|
8
|
+
answered?: {
|
|
9
|
+
label: string;
|
|
10
|
+
at: number;
|
|
11
|
+
} | null;
|
|
12
|
+
fixes?: readonly UiBlockFix[];
|
|
13
|
+
shownAsText?: string | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function UiBlockFooter({ live, answered, fixes, shownAsText }: UiBlockFooterProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
|
|
5
|
+
import { Icon, Spinner, Tooltip, useTickingText } from '@adecore/ui';
|
|
6
|
+
import { formatAgo, formatMoment } from '@adecore/ui/format';
|
|
7
|
+
import { useLater } from './use-later.js';
|
|
8
|
+
const NO_FIXES = [];
|
|
9
|
+
const READING_SPINNER_MS = 1000;
|
|
10
|
+
/* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
|
|
11
|
+
function LivePart({ live }) {
|
|
12
|
+
const { t } = useTranslation('agent-chat');
|
|
13
|
+
const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
|
|
14
|
+
const { readAt } = live;
|
|
15
|
+
const fresh = live.state === 'fresh' || live.state === 'reading';
|
|
16
|
+
const ago = useTickingText(() => {
|
|
17
|
+
if (readAt === null) {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
const time = formatAgo(Date.now() - readAt);
|
|
21
|
+
return fresh ? t('blocks.live.read', { time }) : time;
|
|
22
|
+
});
|
|
23
|
+
const source = live.source ?? live.sources[0] ?? '';
|
|
24
|
+
const icon = slow ? (_jsx(Spinner, { size: 12 })) : live.state === 'failed' ? (_jsx(Icon, { icon: TriangleAlert, size: 12, className: "text-status-needs-you" })) : live.state === 'refused' ? (_jsx(Icon, { icon: Lock, size: 12 })) : (_jsx(Icon, { icon: Radio, size: 12, className: "text-status-idle" }));
|
|
25
|
+
const words = live.state === 'failed'
|
|
26
|
+
? t('blocks.live.failed', { source })
|
|
27
|
+
: live.state === 'refused'
|
|
28
|
+
? t('blocks.live.refused', { source })
|
|
29
|
+
: t('blocks.live.label');
|
|
30
|
+
const hint = live.state === 'failed' && live.reason !== undefined
|
|
31
|
+
? live.reason
|
|
32
|
+
: live.sources.length > 0
|
|
33
|
+
? t('blocks.live.sources', { sources: live.sources.join(', ') })
|
|
34
|
+
: null;
|
|
35
|
+
const body = (_jsxs("span", { tabIndex: hint === null ? undefined : 0, className: "flex items-center gap-1.5", children: [icon, _jsx("span", { children: words }), readAt !== null && live.state !== 'refused' && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("time", { dateTime: new Date(readAt).toISOString(), children: _jsx("span", { ref: ago, className: "tabular-nums" }) })] })), readAt === null && fresh && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("span", { children: t('blocks.live.notRead') })] }))] }));
|
|
36
|
+
return hint === null ? body : _jsx(Tooltip, { label: hint, children: body });
|
|
37
|
+
}
|
|
38
|
+
/*
|
|
39
|
+
* The line under a block, only when it has something to say: live, answered, repaired or drawn as
|
|
40
|
+
* text, in that order. A hairline of the soft border runs the full width of the card over it.
|
|
41
|
+
*/
|
|
42
|
+
export function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }) {
|
|
43
|
+
const { t } = useTranslation('agent-chat');
|
|
44
|
+
const [fixesOpen, setFixesOpen] = useState(false);
|
|
45
|
+
const listId = useId();
|
|
46
|
+
if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return (_jsxs("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", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1", children: [live !== null && _jsx(LivePart, { live: live }), answered !== null && (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { children: t('blocks.answered', { label: answered.label }) }), _jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("time", { dateTime: new Date(answered.at).toISOString(), children: formatMoment(answered.at) })] })), fixes.length > 0 && (_jsxs("button", { type: "button", "aria-expanded": fixesOpen, "aria-controls": listId, className: "flex items-center gap-1.5 rounded-sm hover:text-text-muted", onClick: () => setFixesOpen(!fixesOpen), children: [_jsx(Icon, { icon: Wrench, size: 12 }), t('blocks.fixes', { count: fixes.length })] })), shownAsText !== null && (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { children: t('blocks.shownAsText') }), _jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("span", { children: shownAsText })] }))] }), fixes.length > 0 && (_jsx("ul", { id: listId, hidden: !fixesOpen, className: "flex flex-col gap-0.5 text-text-muted", children: fixes.map((fix, index) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { className: "shrink-0 font-mono", children: fix.code }), _jsx("span", { className: "min-w-0", children: fix.message })] }, index))) }))] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { UiViewNode } from '@adecore/intelligent-ui';
|
|
3
|
+
import { type UiBlockFooterProps } from './UiBlockFooter.js';
|
|
4
|
+
export interface UiBlockFrameProps extends UiBlockFooterProps {
|
|
5
|
+
nodes: readonly UiViewNode[];
|
|
6
|
+
phase: 'streaming' | 'final';
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function UiBlockFrame({ nodes, phase, children, className, ...footer }: UiBlockFrameProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Skeleton } from '@adecore/ui';
|
|
6
|
+
import { uiBlockHead } from './node-text.js';
|
|
7
|
+
import { UiHeadContext } from './render-context.js';
|
|
8
|
+
import { UiBlockFooter } from './UiBlockFooter.js';
|
|
9
|
+
const RELEASE_MS = 200;
|
|
10
|
+
/*
|
|
11
|
+
* The tallest the card has been, while `active`: a streaming block grows and never shrinks until its
|
|
12
|
+
* last compile. Then the card eases down to its own height, or drops there for a person who asked
|
|
13
|
+
* for less motion.
|
|
14
|
+
*/
|
|
15
|
+
function useRisingFloor(active) {
|
|
16
|
+
const ref = useRef(null);
|
|
17
|
+
const held = useRef(0);
|
|
18
|
+
const [floor, setFloor] = useState(0);
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
const element = ref.current;
|
|
21
|
+
if (element === null) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (active) {
|
|
25
|
+
const observer = new ResizeObserver(() => {
|
|
26
|
+
const height = Math.ceil(element.getBoundingClientRect().height);
|
|
27
|
+
held.current = Math.max(held.current, height);
|
|
28
|
+
setFloor(held.current);
|
|
29
|
+
});
|
|
30
|
+
observer.observe(element);
|
|
31
|
+
return () => {
|
|
32
|
+
observer.disconnect();
|
|
33
|
+
setFloor(0);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// The final render already let go of the floor, so the card measures its own height here.
|
|
37
|
+
const from = held.current;
|
|
38
|
+
const to = element.getBoundingClientRect().height;
|
|
39
|
+
held.current = 0;
|
|
40
|
+
const reduced = element.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
|
|
41
|
+
if (from - to < 1 || reduced) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const release = element.animate([{ minHeight: `${from}px` }, { minHeight: `${to}px` }], { duration: RELEASE_MS, easing: 'ease-out' });
|
|
45
|
+
return () => release.cancel();
|
|
46
|
+
}, [active]);
|
|
47
|
+
return [ref, floor];
|
|
48
|
+
}
|
|
49
|
+
/*
|
|
50
|
+
* The card of one block, with its footer. Named by its Summary and busy while it streams, but never
|
|
51
|
+
* live: reading out every compile would be noise. A node fades in as it arrives, without a stagger,
|
|
52
|
+
* and for a person who asked for less motion it only fades; a node inside another only fades
|
|
53
|
+
* (`chat-ui-nodes`). Until the first node a skeleton holds the place of the head.
|
|
54
|
+
*/
|
|
55
|
+
export function UiBlockFrame({ nodes, phase, children, className, ...footer }) {
|
|
56
|
+
const { t } = useTranslation('agent-chat');
|
|
57
|
+
const streaming = phase === 'streaming';
|
|
58
|
+
const head = uiBlockHead(nodes);
|
|
59
|
+
const [ref, floor] = useRisingFloor(streaming);
|
|
60
|
+
return (_jsx(UiHeadContext, { value: head?.id ?? null, children: _jsxs("div", { ref: ref, role: "group", "aria-label": head === null || head.label === '' ? t('blocks.label') : head.label, "aria-busy": streaming || undefined, className: clsx('flex min-h-11 flex-col rounded-2xl border border-border bg-surface-raised p-2.25', className), style: streaming && floor > 0 ? { minHeight: floor } : undefined, children: [_jsx("div", { className: "flex flex-col gap-3 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5", children: nodes.length === 0 && streaming ? _jsx(Skeleton, { className: "mx-2 my-1 w-2/5" }) : children }), _jsx(UiBlockFooter, { ...footer })] }) }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Markdown } from '../Markdown.js';
|
|
4
|
+
/*
|
|
5
|
+
* One part of a block as the markdown it stands for, in a quiet surface with a line that says why.
|
|
6
|
+
* A person sees it as a repair, never as an error, so nothing here is red.
|
|
7
|
+
*/
|
|
8
|
+
export function UiFallbackPart({ fallback, problem }) {
|
|
9
|
+
const { t } = useTranslation('agent-chat');
|
|
10
|
+
return (_jsxs("div", { className: "flex flex-col gap-1 rounded-md bg-surface-hover p-2", children: [_jsx("span", { className: "text-xs text-text-faint", children: problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed') }), fallback !== '' && _jsx(Markdown, { text: fallback, fileLinks: false })] }));
|
|
11
|
+
}
|