@adecore/agents-react 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/logic/timeline-target.d.ts +6 -0
  3. package/dist/chat/logic/timeline-target.js +23 -3
  4. package/dist/chat/logic/timeline.js +9 -7
  5. package/dist/chat/ui/CodeBlock.d.ts +3 -1
  6. package/dist/chat/ui/CodeBlock.js +38 -7
  7. package/dist/chat/ui/Composer.js +13 -3
  8. package/dist/chat/ui/Markdown.d.ts +7 -1
  9. package/dist/chat/ui/Markdown.js +80 -23
  10. package/dist/chat/ui/MessageActions.js +6 -7
  11. package/dist/chat/ui/SubagentTimeline.js +1 -1
  12. package/dist/chat/ui/Timeline.js +1 -1
  13. package/dist/chat/ui/TimelineMenu.js +7 -3
  14. package/dist/chat/ui/file-links.d.ts +2 -1
  15. package/dist/chat/ui/file-links.js +8 -3
  16. package/dist/chat/ui/rows/MessageRows.js +6 -3
  17. package/dist/host.d.ts +14 -2
  18. package/dist/host.js +1 -0
  19. package/dist/locales/en/agent-chat.json +7 -1
  20. package/dist/locales/nl/agent-chat.json +7 -1
  21. package/dist/theme.css +19 -2
  22. package/package.json +3 -3
  23. package/src/chat/chat-client.ts +1 -0
  24. package/src/chat/logic/timeline-target.ts +28 -3
  25. package/src/chat/logic/timeline.ts +9 -7
  26. package/src/chat/ui/CodeBlock.tsx +70 -21
  27. package/src/chat/ui/Composer.tsx +22 -5
  28. package/src/chat/ui/Markdown.tsx +123 -26
  29. package/src/chat/ui/MessageActions.tsx +15 -8
  30. package/src/chat/ui/SubagentTimeline.tsx +2 -0
  31. package/src/chat/ui/Timeline.tsx +1 -1
  32. package/src/chat/ui/TimelineMenu.tsx +6 -3
  33. package/src/chat/ui/file-links.ts +9 -3
  34. package/src/chat/ui/rows/MessageRows.tsx +9 -3
  35. package/src/host.ts +19 -2
  36. package/src/locales/en/agent-chat.json +7 -1
  37. package/src/locales/nl/agent-chat.json +7 -1
  38. package/src/theme.css +19 -2
@@ -1,7 +1,7 @@
1
- import { memo, useMemo, type ReactNode } from 'react';
1
+ import { createContext, memo, useContext, useMemo, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { Zap } from 'lucide-react';
4
- import ReactMarkdown, { type Components, type Options } from 'react-markdown';
4
+ import ReactMarkdown, { type Components, type ExtraProps, type Options } from 'react-markdown';
5
5
  import remarkBreaks from 'remark-breaks';
6
6
  import remarkGfm from 'remark-gfm';
7
7
  import { CHIP_IN_MESSAGE, MENTION_TONE, SKILL_TONE } from './chips';
@@ -11,10 +11,55 @@ import { splitMarkdownBlocks } from './markdown-blocks';
11
11
  import { rehypeChips, type ChipOptions } from './rehype-chips';
12
12
  import { rehypeFadeWords } from './rehype-fade';
13
13
  import { remarkHtmlAsText } from './remark-html-as-text';
14
- import type { FileRef } from '../../host';
15
- import { openFileLink, useFileLinkCwd, useFileLinkTarget } from './file-links';
14
+ import { chatHost, type FileRef, type ShellCodeBlockContext } from '../../host';
15
+ import { openFileLink, useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget } from './file-links';
16
16
  import { FileIcon, Icon } from '@adecore/ui';
17
17
 
18
+ type ShellCodeBlockReply = Pick<ShellCodeBlockContext, 'scopeId' | 'chatId' | 'itemId'> & { streaming: boolean };
19
+
20
+ const ShellCodeBlockReplyContext = createContext<ShellCodeBlockReply | null>(null);
21
+ const BlockCodeContext = createContext(false);
22
+
23
+ interface MarkdownNode {
24
+ type: string;
25
+ children?: MarkdownNode[];
26
+ position?: { start: { offset?: number }; end: { offset?: number } };
27
+ data?: { hProperties?: Record<string, unknown> };
28
+ }
29
+
30
+ /* Only root children qualify; Markdown's parser decides whether a fence belongs to a list or quote. */
31
+ function remarkShellCodeBlocks() {
32
+ return (tree: MarkdownNode, file: { value: unknown }) => {
33
+ for (const node of tree.children ?? []) {
34
+ if (node.type !== 'code') {
35
+ continue;
36
+ }
37
+ const start = node.position?.start.offset;
38
+ const end = node.position?.end.offset;
39
+ if (start === undefined || end === undefined) {
40
+ continue;
41
+ }
42
+ const raw = String(file.value).slice(start, end);
43
+ const opening = /^ {0,3}(`{3,}|~{3,})[ \t]*(sh|bash|zsh)[ \t]*\r?\n/.exec(raw);
44
+ if (opening === null) {
45
+ continue;
46
+ }
47
+ const body = raw.slice(opening[0].length);
48
+ const closing = /(?:^|\r?\n) {0,3}(`{3,}|~{3,})[ \t]*$/.exec(body);
49
+ const closed = closing !== null && closing[1]![0] === opening[1]![0] && closing[1]!.length >= opening[1]!.length;
50
+ node.data = {
51
+ ...node.data,
52
+ hProperties: {
53
+ ...node.data?.hProperties,
54
+ 'data-shell-language': opening[2],
55
+ 'data-shell-code': closed ? body.slice(0, closing.index) : body,
56
+ 'data-shell-closed': closed
57
+ }
58
+ };
59
+ }
60
+ };
61
+ }
62
+
18
63
  function languageOf(className: string | undefined): string {
19
64
  return /language-([\w-]+)/.exec(className ?? '')?.[1] ?? 'text';
20
65
  }
@@ -29,6 +74,7 @@ const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code
29
74
  */
30
75
  function FileLink({ target, className, children }: { target: FileRef; className?: string; children: ReactNode }) {
31
76
  const cwd = useFileLinkCwd();
77
+ const scopeId = useFileLinkScopeId();
32
78
  return (
33
79
  // `select-text` because a thread is copied as often as it is clicked, and a button is not
34
80
  // selectable on its own.
@@ -37,7 +83,12 @@ function FileLink({ target, className, children }: { target: FileRef; className?
37
83
  className={clsx('cursor-pointer select-text', className)}
38
84
  data-file-path={target.path}
39
85
  data-file-line={target.line}
40
- onClick={() => openFileLink(cwd, target)}
86
+ data-file-column={target.column}
87
+ data-file-end-line={target.endLine}
88
+ data-file-directory={target.directory}
89
+ data-file-cwd={cwd ?? ''}
90
+ data-file-scope-id={scopeId ?? ''}
91
+ onClick={() => openFileLink(cwd, target, scopeId)}
41
92
  >
42
93
  {children}
43
94
  </button>
@@ -85,18 +136,20 @@ function MarkdownLink({ href, children }: { href?: string; children?: ReactNode
85
136
  );
86
137
  }
87
138
 
139
+ function MarkdownCode({ className, children, plain = false }: { className?: string; children?: ReactNode; plain?: boolean }) {
140
+ const block = useContext(BlockCodeContext);
141
+ const text = String(children ?? '');
142
+ if (block) {
143
+ return <CodeBlock code={text.replace(/\n$/, '')} lang={languageOf(className)} />;
144
+ }
145
+ return plain ? <code className={INLINE_CODE}>{text}</code> : <InlineCode text={text} />;
146
+ }
147
+
88
148
  const components = {
89
149
  pre({ children }: { children?: ReactNode }) {
90
- return <>{children}</>;
91
- },
92
- code({ className, children }: { className?: string; children?: ReactNode }) {
93
- const text = String(children ?? '');
94
- // Fenced blocks end with a newline and carry a language; anything else is inline.
95
- if (className?.startsWith('language-') || text.includes('\n')) {
96
- return <CodeBlock code={text.replace(/\n$/, '')} lang={languageOf(className)} />;
97
- }
98
- return <InlineCode text={text} />;
150
+ return <BlockCodeContext.Provider value={true}>{children}</BlockCodeContext.Provider>;
99
151
  },
152
+ code: MarkdownCode,
100
153
  a({ href, children }: { href?: string; children?: ReactNode }) {
101
154
  return <MarkdownLink href={href}>{children}</MarkdownLink>;
102
155
  },
@@ -114,11 +167,11 @@ const components = {
114
167
  const plainComponents = {
115
168
  ...components,
116
169
  code({ className, children }: { className?: string; children?: ReactNode }) {
117
- const text = String(children ?? '');
118
- if (className?.startsWith('language-') || text.includes('\n')) {
119
- return <CodeBlock code={text.replace(/\n$/, '')} lang={languageOf(className)} />;
120
- }
121
- return <code className={INLINE_CODE}>{text}</code>;
170
+ return (
171
+ <MarkdownCode className={className} plain>
172
+ {children}
173
+ </MarkdownCode>
174
+ );
122
175
  },
123
176
  a({ href, children }: { href?: string; children?: ReactNode }) {
124
177
  return (
@@ -142,6 +195,7 @@ function replyHeading(level: 1 | 2 | 3 | 4 | 5 | 6) {
142
195
 
143
196
  const replyComponents = {
144
197
  ...components,
198
+ code: ReplyCode,
145
199
  h1: replyHeading(1),
146
200
  h2: replyHeading(2),
147
201
  h3: replyHeading(3),
@@ -150,6 +204,36 @@ const replyComponents = {
150
204
  h6: replyHeading(6)
151
205
  };
152
206
 
207
+ function ReplyCode({ className, children, node }: { className?: string; children?: ReactNode } & ExtraProps) {
208
+ const reply = useContext(ShellCodeBlockReplyContext);
209
+ const block = useContext(BlockCodeContext);
210
+ const properties = node?.properties;
211
+ const language = properties?.['data-shell-language'];
212
+ const code = properties?.['data-shell-code'];
213
+ const context: ShellCodeBlockContext | null =
214
+ reply !== null && (language === 'sh' || language === 'bash' || language === 'zsh') && typeof code === 'string'
215
+ ? {
216
+ scopeId: reply.scopeId,
217
+ chatId: reply.chatId,
218
+ itemId: reply.itemId,
219
+ language,
220
+ code,
221
+ complete: !reply.streaming && properties?.['data-shell-closed'] === true
222
+ }
223
+ : null;
224
+ const text = String(children ?? '');
225
+ if (block) {
226
+ return (
227
+ <CodeBlock
228
+ code={text.replace(/\n$/, '')}
229
+ lang={languageOf(className)}
230
+ actions={context !== null ? chatHost().renderShellCodeBlock?.(context) : undefined}
231
+ />
232
+ );
233
+ }
234
+ return <InlineCode text={text} />;
235
+ }
236
+
153
237
  /* A picked file or skill in a sent message. The glyph stands in for the sigil the text still carries. */
154
238
  function Chip({ kind, value }: { kind: string; value: string }) {
155
239
  if (kind === 'skill') {
@@ -162,7 +246,7 @@ function Chip({ kind, value }: { kind: string; value: string }) {
162
246
  }
163
247
  return (
164
248
  // The path is what the thread's menu opens in the preview from here.
165
- <span className={clsx(CHIP_IN_MESSAGE, MENTION_TONE)} data-file-path={value}>
249
+ <span className={clsx(CHIP_IN_MESSAGE, MENTION_TONE)} data-file-path={value} data-file-directory={/[\\/]$/.test(value)}>
166
250
  <FileIcon path={value} size={14} />
167
251
  <span className="truncate">{value}</span>
168
252
  </span>
@@ -181,6 +265,7 @@ const messageComponents = {
181
265
 
182
266
  // Module constants, so a render never hands react-markdown a fresh array and makes it parse again.
183
267
  const PLUGINS = [remarkGfm];
268
+ const REPLY_PLUGINS = [remarkGfm, remarkShellCodeBlocks];
184
269
  const PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
185
270
  const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
186
271
  const FADE_PLUGINS = [rehypeFadeWords];
@@ -188,14 +273,16 @@ const NO_PLUGINS: typeof FADE_PLUGINS = [];
188
273
 
189
274
  /* One block of a reply. It renders no element of its own, so prose still sees the paragraphs as
190
275
  direct children and keeps its first and last margins. */
191
- const ReplyBlock = memo(function ReplyBlock({ text, fade, open }: { text: string; fade: boolean; open: boolean }) {
276
+ const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }: { text: string; fade: boolean; open: boolean; reply?: ShellCodeBlockReply }) {
192
277
  return (
193
278
  // A context rather than a second set of components. A component that changed would mount the
194
279
  // code block again the moment its fence closes, and it would lose the lines it already has.
195
280
  <CodeStreamingContext.Provider value={open}>
196
- <ReactMarkdown remarkPlugins={PLUGINS} rehypePlugins={fade ? FADE_PLUGINS : NO_PLUGINS} components={replyComponents}>
197
- {text}
198
- </ReactMarkdown>
281
+ <ShellCodeBlockReplyContext.Provider value={reply ?? null}>
282
+ <ReactMarkdown remarkPlugins={REPLY_PLUGINS} rehypePlugins={fade ? FADE_PLUGINS : NO_PLUGINS} components={replyComponents}>
283
+ {text}
284
+ </ReactMarkdown>
285
+ </ShellCodeBlockReplyContext.Provider>
199
286
  </CodeStreamingContext.Provider>
200
287
  );
201
288
  });
@@ -206,13 +293,23 @@ const ReplyBlock = memo(function ReplyBlock({ text, fade, open }: { text: string
206
293
  * line at a time as it grows. `arriving` fades in each block as it is added instead, for a reply
207
294
  * that is shown a block at a time.
208
295
  */
209
- export const ReplyMarkdown = memo(function ReplyMarkdown({ text, streaming, arriving = false }: { text: string; streaming: boolean; arriving?: boolean }) {
296
+ export const ReplyMarkdown = memo(function ReplyMarkdown({
297
+ text,
298
+ streaming,
299
+ arriving = false,
300
+ reply
301
+ }: {
302
+ text: string;
303
+ streaming: boolean;
304
+ arriving?: boolean;
305
+ reply?: ShellCodeBlockReply;
306
+ }) {
210
307
  const blocks = useMemo(() => splitMarkdownBlocks(text), [text]);
211
308
  return (
212
309
  <div className="chat-markdown prose prose-sm max-w-none text-sm" data-arriving={arriving || undefined}>
213
310
  {blocks.map((block, index) => (
214
311
  // Blocks only ever grow at the end, so the place of a block is a stable key.
215
- <ReplyBlock key={index} text={block.text} fade={streaming} open={streaming && block.openFence} />
312
+ <ReplyBlock key={index} text={block.text} fade={streaming} open={streaming && block.openFence} reply={reply} />
216
313
  ))}
217
314
  </div>
218
315
  );
@@ -9,19 +9,18 @@ import { markdownOf, messageTextOf } from '../logic/timeline-copy';
9
9
  import { chatHost } from '../../host';
10
10
  import { useChatScope } from '../../scope';
11
11
  import { useChatRow, useChats } from '../../state/chats';
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
 
14
15
  const COPIED_MS = 1500;
16
+ const TIMESTAMP_FORMAT: Intl.DateTimeFormatOptions = { dateStyle: 'full', timeStyle: 'medium' };
15
17
 
16
18
  type MessageRow = Extract<TimelineRow, { kind: 'user' | 'assistant' }>;
17
19
 
18
- /*
19
- * Bookmark, fork and copy under a message, shown while the pointer or the focus is on a
20
- * `group/message`. The row always takes its height, so the virtualizer never measures it again on a
21
- * hover. Like the strip's card, it leaves out an action the message cannot take right now.
22
- */
20
+ /* Hidden actions keep their height, so hovering never forces the virtualizer to remeasure. */
23
21
  export function MessageActions({ chatId, row }: { chatId: string; row: MessageRow }) {
24
22
  const { t } = useTranslation('agent-chat');
23
+ useFormatLocale();
25
24
  const scope = useChatScope();
26
25
  const { fork } = chatHost();
27
26
  const turnId = row.item.turnId;
@@ -61,8 +60,8 @@ export function MessageActions({ chatId, row }: { chatId: string; row: MessageRo
61
60
  role="toolbar"
62
61
  aria-label={t('timeline.actions.label')}
63
62
  className={clsx(
64
- 'flex h-7 items-center opacity-0 transition-opacity group-has-focus-visible/message:opacity-100 group-hover/message:opacity-100',
65
- row.kind === 'user' ? '-mr-1.5 mt-1.5 justify-end' : '-ml-1.5'
63
+ 'flex h-7 items-center gap-2 opacity-0 transition-opacity group-has-focus-visible/message:opacity-100 group-hover/message:opacity-100',
64
+ row.kind === 'user' && 'mt-1.5 justify-end'
66
65
  )}
67
66
  >
68
67
  {!streaming && (
@@ -83,6 +82,14 @@ export function MessageActions({ chatId, row }: { chatId: string; row: MessageRo
83
82
  )}
84
83
  </ButtonGroup>
85
84
  )}
85
+ <Tooltip label={formatDateTime(row.item.createdAt, TIMESTAMP_FORMAT)}>
86
+ <time
87
+ dateTime={new Date(row.item.createdAt).toISOString()}
88
+ className={clsx('shrink-0 text-xs text-text-faint tabular-nums select-none', row.kind === 'assistant' && 'order-first')}
89
+ >
90
+ {formatClock(row.item.createdAt)}
91
+ </time>
92
+ </Tooltip>
86
93
  </div>
87
94
  );
88
95
  }
@@ -117,6 +117,8 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
117
117
  <ReplyContext.Provider value={state.context ?? { provider: state.source === 'codex-thread' ? 'codex' : 'claude' }}>
118
118
  <div
119
119
  ref={scrollRef}
120
+ data-file-cwd={state.context?.cwd ?? cwd ?? ''}
121
+ data-file-scope-id={scope.id}
120
122
  className="chat-thread relative min-h-0 grow overflow-auto px-4 pt-4 pb-3"
121
123
  // The thread keeps its reader in place itself when a page goes in above.
122
124
  style={{ overflowAnchor: 'none' }}
@@ -498,7 +498,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
498
498
  // A file an answer names is relative to the folder this chat runs in, which is a worktree as
499
499
  // often as it is the project itself.
500
500
  <FileLinkContext.Provider value={info?.cwd ?? null}>
501
- <div ref={frameRef} className="relative flex min-h-0 grow flex-col">
501
+ <div ref={frameRef} className="relative flex min-h-0 grow flex-col" data-file-cwd={info?.cwd ?? ''} data-file-scope-id={scope.id}>
502
502
  {searching && chatFind.bar}
503
503
  <ContextMenu.Root>
504
504
  <div
@@ -8,7 +8,7 @@ import type { TimelineTarget } from '../logic/timeline-target';
8
8
  import { chatHost } from '../../host';
9
9
  import { useChatScope } from '../../scope';
10
10
  import { useChatRow } from '../../state/chats';
11
- import { openFileLink, useFileLinkCwd } from './file-links';
11
+ import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links';
12
12
  import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
13
13
 
14
14
  /*
@@ -34,10 +34,13 @@ export function TimelineMenuPopup({
34
34
  const markable = chatId !== null && (target.row?.kind === 'user' || target.row?.kind === 'assistant') ? target.row.id : null;
35
35
  // A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
36
36
  const cwd = useFileLinkCwd();
37
+ const scopeId = useFileLinkScopeId();
37
38
  const { fork, fileLinks } = chatHost();
38
39
  const openInPreview = (): void => {
39
- if (target.path !== null) {
40
- openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false });
40
+ if (target.file != null) {
41
+ openFileLink(target.file.cwd, target.file.ref, target.file.scopeId);
42
+ } else if (target.path !== null) {
43
+ openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false }, scopeId);
41
44
  }
42
45
  };
43
46
  return (
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import { chatHost, type FileRef } from '../../host';
3
+ import { ChatScopeContext } from '../../scope';
3
4
 
4
5
  /*
5
6
  * The folder a relative reference in rendered text counts from: a chat's own cwd, which is a
@@ -12,13 +13,18 @@ export function useFileLinkCwd(): string | null {
12
13
  return useContext(FileLinkContext);
13
14
  }
14
15
 
16
+ export function useFileLinkScopeId(): string | null {
17
+ return useContext(ChatScopeContext)?.id ?? null;
18
+ }
19
+
15
20
  /* The reference a piece of text names, only where this spot on screen could open it. */
16
21
  export function useFileLinkTarget(text: string): FileRef | null {
17
22
  const cwd = useFileLinkCwd();
18
- return useMemo(() => chatHost().fileLinks?.target(text, cwd) ?? null, [text, cwd]);
23
+ const scopeId = useFileLinkScopeId();
24
+ return useMemo(() => chatHost().fileLinks?.target(text, cwd, scopeId) ?? null, [text, cwd, scopeId]);
19
25
  }
20
26
 
21
27
  /* A reference followed, the way the app opens a file or a folder. */
22
- export function openFileLink(cwd: string | null, ref: FileRef): void {
23
- chatHost().fileLinks?.open(cwd, ref);
28
+ export function openFileLink(cwd: string | null, ref: FileRef, scopeId: string | null = null): void {
29
+ chatHost().fileLinks?.open(cwd, ref, scopeId);
24
30
  }
@@ -1,4 +1,4 @@
1
- import { useContext, useState, type ReactNode } from 'react';
1
+ import { useContext, useMemo, useState, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Bot, Brain, Check, ChevronDown, CircleAlert, File, Info, MessageCircleQuestionMark, TriangleAlert, X } from 'lucide-react';
@@ -188,6 +188,12 @@ function Writing() {
188
188
  */
189
189
  export function AssistantRow({ chatId, item: derived }: { chatId: string; item: ChatAssistantItem }) {
190
190
  const item = useCurrentItem(chatId, derived);
191
+ const scope = useChatScope();
192
+ const context = useContext(ReplyContext);
193
+ const reply = useMemo(
194
+ () => (context === null && !item.parentToolUseId ? { scopeId: scope.id, chatId, itemId: item.id, streaming: item.streaming } : undefined),
195
+ [context, item.parentToolUseId, scope.id, chatId, item.id, item.streaming]
196
+ );
191
197
  const mode = chatHost().useStreaming();
192
198
  const [sawWriting] = useState(item.streaming);
193
199
  const live = mode === 'words' && item.streaming;
@@ -200,7 +206,7 @@ export function AssistantRow({ chatId, item: derived }: { chatId: string; item:
200
206
  <ReplyHeading chatId={chatId} />
201
207
  {settled !== '' && (
202
208
  <div data-find-field="text" data-quote-answer className="contents">
203
- <ReplyMarkdown text={settled} streaming={false} arriving={sawWriting} />
209
+ <ReplyMarkdown text={settled} streaming={false} arriving={sawWriting} reply={reply} />
204
210
  </div>
205
211
  )}
206
212
  {item.streaming && <Writing />}
@@ -219,7 +225,7 @@ export function AssistantRow({ chatId, item: derived }: { chatId: string; item:
219
225
  <div data-find-item={item.id} className={clsx('-mx-1 px-1 pb-2', mode === 'whole' && sawWriting && WHOLE_FADE_CLASS)}>
220
226
  <ReplyHeading chatId={chatId} />
221
227
  <div data-find-field="text" data-quote-answer className="contents">
222
- <ReplyMarkdown text={reveal.text} streaming={reveal.active} />
228
+ <ReplyMarkdown text={reveal.text} streaming={reveal.active} reply={reply} />
223
229
  </div>
224
230
  {live && item.text === '' && <span className="inline-block h-3.5 w-1.5 animate-pulse rounded-sm bg-text-faint align-middle" />}
225
231
  </div>
package/src/host.ts CHANGED
@@ -56,10 +56,22 @@ export interface FileRef {
56
56
  path: string;
57
57
  /* One-based, when the reference named a line. */
58
58
  line?: number;
59
+ column?: number;
60
+ endLine?: number;
59
61
  /* A trailing separator is the only thing that says a reference means a directory. */
60
62
  directory: boolean;
61
63
  }
62
64
 
65
+ export interface ShellCodeBlockContext {
66
+ scopeId: string;
67
+ chatId: string;
68
+ itemId: string;
69
+ language: 'sh' | 'bash' | 'zsh';
70
+ code: string;
71
+ /* A closed fence in a main reply that has stopped streaming. */
72
+ complete: boolean;
73
+ }
74
+
63
75
  /* Where another chat stands and how to get there; a null title is a chat that is gone. */
64
76
  export interface ChatPlace {
65
77
  title: string | null;
@@ -210,12 +222,16 @@ export interface ChatHost {
210
222
  };
211
223
  /* How a reply appears while it streams: a word, a block or the whole of it at a time. */
212
224
  useStreaming(): 'words' | 'blocks' | 'whole';
225
+ /* How Send handles a running turn; Option or Alt selects the other behavior. */
226
+ useSendDelivery(): 'queue' | 'steer';
213
227
  /* Paths in an answer as links; null leaves them text. */
214
228
  fileLinks: {
215
229
  /* The reference a piece of text names, where the folder it counts from lets it be opened. */
216
- target(text: string, cwd: string | null): FileRef | null;
217
- open(cwd: string | null, ref: FileRef): void;
230
+ target(text: string, cwd: string | null, scopeId?: string | null): FileRef | null;
231
+ open(cwd: string | null, ref: FileRef, scopeId?: string | null): void;
218
232
  } | null;
233
+ /* Renders in a main reply's shell block action group. Rendering must not execute an action. */
234
+ renderShellCodeBlock?: (context: ShellCodeBlockContext) => ReactNode;
219
235
  /* Find in a thread; without one the thread has no find. */
220
236
  useTimelineFind(options: TimelineFindOptions): TimelineFind;
221
237
  dictation: {
@@ -296,6 +312,7 @@ const DEFAULT_HOST: ChatHost = {
296
312
  ReadImage: null,
297
313
  code: { useMode: () => 'light', useThemes: () => ({ light: 'github-light', dark: 'github-dark' }), custom: [] },
298
314
  useStreaming: () => 'words',
315
+ useSendDelivery: () => 'queue',
299
316
  fileLinks: null,
300
317
  useTimelineFind: () => NO_FIND,
301
318
  dictation: { Textarea: PlainTextarea, composer: null },
@@ -307,7 +307,9 @@
307
307
  "description": "A turn is running. Stop it and clear the thread?",
308
308
  "confirm": "Stop and clear"
309
309
  },
310
- "waitingBelow": "Waiting for your answer below"
310
+ "waitingBelow": "Waiting for your answer below",
311
+ "steerButton": "Send to the running agent. Hold Option or Alt to queue after this turn.",
312
+ "queueWithSteerButton": "Queue after this turn. Hold Option or Alt to send to the running agent."
311
313
  },
312
314
  "activity": {
313
315
  "subagents_one": "{{count}} sub-agent",
@@ -454,5 +456,9 @@
454
456
  "failed": "failed",
455
457
  "cancelled": "cancelled"
456
458
  }
459
+ },
460
+ "code": {
461
+ "actions": "Code actions",
462
+ "copyFailed": "Could not copy the code. Try again."
457
463
  }
458
464
  }
@@ -307,7 +307,9 @@
307
307
  "description": "Er loopt een turn. Die stoppen en de thread wissen?",
308
308
  "confirm": "Stoppen en wissen"
309
309
  },
310
- "waitingBelow": "Wacht hieronder op je antwoord"
310
+ "waitingBelow": "Wacht hieronder op je antwoord",
311
+ "steerButton": "Stuur naar de werkende agent. Houd Option of Alt ingedrukt om te wachten tot deze beurt klaar is.",
312
+ "queueWithSteerButton": "Wacht tot deze beurt klaar is. Houd Option of Alt ingedrukt om naar de werkende agent te sturen."
311
313
  },
312
314
  "activity": {
313
315
  "subagents_one": "{{count}} sub-agent",
@@ -454,5 +456,9 @@
454
456
  "failed": "mislukt",
455
457
  "cancelled": "geannuleerd"
456
458
  }
459
+ },
460
+ "code": {
461
+ "actions": "Codeacties",
462
+ "copyFailed": "De code kon niet worden gekopieerd. Probeer het opnieuw."
457
463
  }
458
464
  }
package/src/theme.css CHANGED
@@ -193,9 +193,26 @@
193
193
  .chat-markdown :is(th, td) { border: 1px solid var(--tw-prose-td-borders); padding: 4px 8px; text-align: left; }
194
194
 
195
195
  /* A code block is shiki's own `<pre>`, which brings a background of its own to override. */
196
- .chat-code { border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: auto; }
197
- .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
196
+ .chat-code { position: relative; min-height: 38px; border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: hidden; }
197
+ .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); overflow: auto; user-select: text; background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
198
198
  .chat-code code { font-family: inherit; }
199
+ .chat-code-actions { position: absolute; top: 4px; right: 4px; border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); box-shadow: 0 0 0 1px var(--border); }
200
+ .chat-code-error { margin: 0; padding: 0 12px 10px; color: var(--status-error); font-size: var(--text-sm); }
201
+ @media (hover: hover) and (pointer: fine) {
202
+ .chat-code-actions { opacity: 0; pointer-events: none; }
203
+ .chat-code:hover .chat-code-actions, .chat-code:focus-within .chat-code-actions,
204
+ .chat-code[data-copy-state="failed"] .chat-code-actions, .chat-code-actions:has([aria-busy="true"]) { opacity: 1; pointer-events: auto; }
205
+ }
206
+ @media (pointer: coarse) {
207
+ .chat-code { min-height: 54px; }
208
+ .chat-code-actions { padding: 8px; gap: 17px; }
209
+ .chat-code-actions .icon-btn { position: relative; }
210
+ .chat-code-actions .icon-btn::after { content: ''; position: absolute; inset: -8px; }
211
+ }
212
+ @media (prefers-reduced-motion: reduce) {
213
+ .chat-code-actions .icon-btn { transition: none; }
214
+ .chat-code-actions .icon-btn:active { scale: 1; }
215
+ }
199
216
 
200
217
  /* The chat composer is a CodeMirror editor, whose base theme lands in an unlayered style tag at
201
218
  mount. These rules stay unlayered too, and carry one class more than the theme's selectors. */