@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.
Files changed (134) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/logic/generated-image.d.ts +27 -0
  3. package/dist/chat/logic/generated-image.js +32 -0
  4. package/dist/chat/logic/timeline-target.d.ts +19 -1
  5. package/dist/chat/logic/timeline-target.js +49 -3
  6. package/dist/chat/logic/timeline.js +25 -17
  7. package/dist/chat/subagent-conversation.d.ts +2 -1
  8. package/dist/chat/subagent-conversation.js +7 -1
  9. package/dist/chat/timeline-flash.d.ts +3 -1
  10. package/dist/chat/timeline-flash.js +2 -2
  11. package/dist/chat/ui/CodeBlock.d.ts +3 -1
  12. package/dist/chat/ui/CodeBlock.js +38 -7
  13. package/dist/chat/ui/Composer.js +13 -3
  14. package/dist/chat/ui/ImageView.d.ts +8 -2
  15. package/dist/chat/ui/ImageView.js +12 -6
  16. package/dist/chat/ui/Markdown.d.ts +7 -1
  17. package/dist/chat/ui/Markdown.js +80 -23
  18. package/dist/chat/ui/MessageActions.js +6 -7
  19. package/dist/chat/ui/SubagentTimeline.js +7 -3
  20. package/dist/chat/ui/Timeline.js +68 -8
  21. package/dist/chat/ui/TimelineMenu.js +7 -3
  22. package/dist/chat/ui/file-links.d.ts +2 -1
  23. package/dist/chat/ui/file-links.js +8 -3
  24. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
  25. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
  26. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
  27. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
  28. package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
  29. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
  30. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
  31. package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
  32. package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
  33. package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
  34. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
  35. package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
  36. package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
  37. package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
  38. package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
  39. package/dist/chat/ui/intelligent-ui/registry.js +50 -0
  40. package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
  41. package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
  48. package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
  49. package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
  50. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
  51. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
  52. package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
  53. package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
  54. package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
  55. package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
  56. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
  57. package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
  58. package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
  59. package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
  60. package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
  61. package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
  62. package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
  63. package/dist/chat/ui/intelligent-ui/tones.js +31 -0
  64. package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
  65. package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
  66. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
  67. package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
  68. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
  69. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
  70. package/dist/chat/ui/reply-context.d.ts +8 -0
  71. package/dist/chat/ui/reply-context.js +1 -0
  72. package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
  73. package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
  74. package/dist/chat/ui/rows/MessageRows.js +27 -7
  75. package/dist/chat/ui/rows/Rows.js +4 -2
  76. package/dist/chat/ui/rows/SubagentRow.js +5 -0
  77. package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
  78. package/dist/chat/ui/rows/WorkRows.js +4 -3
  79. package/dist/host.d.ts +25 -3
  80. package/dist/host.js +1 -0
  81. package/dist/locales/en/agent-chat.json +89 -1
  82. package/dist/locales/nl/agent-chat.json +89 -1
  83. package/dist/state/chats.js +9 -0
  84. package/dist/theme.css +38 -2
  85. package/package.json +133 -7
  86. package/src/chat/chat-client.ts +1 -0
  87. package/src/chat/logic/generated-image.ts +48 -0
  88. package/src/chat/logic/timeline-target.ts +69 -4
  89. package/src/chat/logic/timeline.ts +28 -17
  90. package/src/chat/subagent-conversation.ts +13 -2
  91. package/src/chat/timeline-flash.ts +5 -3
  92. package/src/chat/ui/CodeBlock.tsx +70 -21
  93. package/src/chat/ui/Composer.tsx +22 -5
  94. package/src/chat/ui/ImageView.tsx +51 -8
  95. package/src/chat/ui/Markdown.tsx +123 -26
  96. package/src/chat/ui/MessageActions.tsx +15 -8
  97. package/src/chat/ui/SubagentTimeline.tsx +26 -1
  98. package/src/chat/ui/Timeline.tsx +98 -22
  99. package/src/chat/ui/TimelineMenu.tsx +6 -3
  100. package/src/chat/ui/file-links.ts +9 -3
  101. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
  102. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
  103. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
  104. package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
  105. package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
  106. package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
  107. package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
  108. package/src/chat/ui/intelligent-ui/registry.ts +52 -0
  109. package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
  110. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
  111. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
  112. package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
  113. package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
  114. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
  115. package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
  116. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
  117. package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
  118. package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
  119. package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
  120. package/src/chat/ui/intelligent-ui/tones.ts +37 -0
  121. package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
  122. package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
  123. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
  124. package/src/chat/ui/reply-context.ts +7 -0
  125. package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
  126. package/src/chat/ui/rows/MessageRows.tsx +69 -7
  127. package/src/chat/ui/rows/Rows.tsx +4 -2
  128. package/src/chat/ui/rows/SubagentRow.tsx +5 -0
  129. package/src/chat/ui/rows/WorkRows.tsx +20 -5
  130. package/src/host.ts +46 -3
  131. package/src/locales/en/agent-chat.json +89 -1
  132. package/src/locales/nl/agent-chat.json +89 -1
  133. package/src/state/chats.ts +9 -0
  134. package/src/theme.css +38 -2
@@ -1,5 +1,5 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 { openFileLink, useFileLinkCwd, useFileLinkTarget } from './file-links.js';
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
- return (_jsx("button", { type: "button", className: clsx('cursor-pointer select-text', className), "data-file-path": target.path, "data-file-line": target.line, onClick: () => openFileLink(cwd, target), children: children }));
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(_Fragment, { children: children });
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
- const text = String(children ?? '');
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: PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, components: replyComponents, children: text }) }));
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
- * Bookmark, fork and copy under a message, shown while the pointer or the focus is on a
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 (_jsx("div", { role: "toolbar", "aria-label": t('timeline.actions.label'), className: clsx('flex h-7 items-center opacity-0 transition-opacity group-has-focus-visible/message:opacity-100 group-hover/message:opacity-100', row.kind === 'user' ? '-mr-1.5 mt-1.5 justify-end' : '-ml-1.5'), 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) }))] })) }));
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
  }
@@ -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
- setSeeking(tick.bookmark.itemId);
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
- const index = rows.findIndex((row) => row.id === seeking);
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), setSeeking), [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.path !== null) {
29
- openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false });
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
- return useMemo(() => chatHost().fileLinks?.target(text, cwd) ?? null, [text, cwd]);
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,10 @@
1
+ export type UiFallbackProblem = {
2
+ kind: 'unknown';
3
+ component: string;
4
+ } | {
5
+ kind: 'failed';
6
+ };
7
+ export declare function UiFallbackPart({ fallback, problem }: {
8
+ fallback: string;
9
+ problem: UiFallbackProblem;
10
+ }): import("react").JSX.Element;
@@ -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
+ }