@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,8 @@
1
- import { Fragment, memo, useContext, useEffect, useMemo, useState, type CSSProperties } from 'react';
1
+ import { Fragment, memo, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Check, Copy } from 'lucide-react';
5
+ import { ButtonGroup, IconButton } from '@adecore/ui';
3
6
  import type { BundledLanguage, BundledTheme, createHighlighter, ThemeRegistration } from 'shiki/bundle/web';
4
7
  import { IncrementalLines, type CodeToken, type Tokenize } from './code-lines';
5
8
  import { CodeStreamingContext } from './code-streaming';
@@ -10,6 +13,7 @@ type Highlighter = Awaited<ReturnType<typeof createHighlighter>>;
10
13
  type GrammarState = ReturnType<Highlighter['getLastGrammarState']>;
11
14
  // A plain language needs no grammar; shiki draws it in the theme's own foreground.
12
15
  const PLAIN = 'text';
16
+ const COPIED_MS = 1500;
13
17
 
14
18
  let highlighter: Highlighter | null = null;
15
19
  let highlighterLoad: Promise<Highlighter> | null = null;
@@ -123,9 +127,12 @@ const CodeLine = memo(function CodeLine({ tokens }: { tokens: CodeToken[] }) {
123
127
  * fence that is still open is tokenized as it grows, and the same component carries on once it
124
128
  * closes, so closing it does not draw it again.
125
129
  */
126
- export function CodeBlock({ code, lang }: { code: string; lang: string }) {
130
+ export function CodeBlock({ code, lang, actions }: { code: string; lang: string; actions?: ReactNode }) {
131
+ const { t } = useTranslation('agent-chat');
127
132
  const theme = useCodeTheme();
128
133
  const streaming = useContext(CodeStreamingContext);
134
+ const [copyState, setCopyState] = useState<'idle' | 'copying' | 'copied' | 'failed'>('idle');
135
+ const copying = useRef(false);
129
136
  // What loaded last, so a block whose theme changes draws again once the new one is in.
130
137
  const [loaded, setLoaded] = useState<string | null>(null);
131
138
  const tokenizer = tokenizerFor(lang, theme);
@@ -134,6 +141,31 @@ export function CodeBlock({ code, lang }: { code: string; lang: string }) {
134
141
  const lines = useMemo(() => (tokenizer ? new IncrementalLines(tokenizer.tokenize) : null), [tokenizer]);
135
142
  const tokens = useMemo(() => lines?.update(code, !streaming) ?? null, [lines, code, streaming]);
136
143
 
144
+ useEffect(() => {
145
+ if (copyState !== 'copied') {
146
+ return;
147
+ }
148
+ const timer = setTimeout(() => setCopyState('idle'), COPIED_MS);
149
+ return () => clearTimeout(timer);
150
+ }, [copyState]);
151
+
152
+ async function copy(): Promise<void> {
153
+ if (copying.current) {
154
+ return;
155
+ }
156
+ copying.current = true;
157
+ setCopyState('copying');
158
+ try {
159
+ // The best-effort clipboard helper cannot distinguish a refusal from success.
160
+ await navigator.clipboard.writeText(code);
161
+ setCopyState('copied');
162
+ } catch {
163
+ setCopyState('failed');
164
+ } finally {
165
+ copying.current = false;
166
+ }
167
+ }
168
+
137
169
  useEffect(() => {
138
170
  if (tokenizer !== null) {
139
171
  return;
@@ -147,29 +179,46 @@ export function CodeBlock({ code, lang }: { code: string; lang: string }) {
147
179
  };
148
180
  }, [lang, theme, tokenizer]);
149
181
 
150
- if (tokenizer === null || tokens === null) {
151
- const failed = loaded === 'failed';
152
- return (
153
- <div className={clsx('chat-code', !failed && 'invisible')} aria-hidden={failed ? undefined : true}>
154
- <pre>
155
- <code>{code}</code>
156
- </pre>
157
- </div>
158
- );
159
- }
182
+ const pending = (tokenizer === null || tokens === null) && loaded !== 'failed';
160
183
  return (
161
- <div className={clsx('chat-code', waited && WHOLE_FADE_CLASS)}>
162
- <pre style={{ color: tokenizer.fg }}>
184
+ <div className="chat-code" data-copy-state={copyState}>
185
+ <pre
186
+ className={clsx(pending && 'invisible', !pending && tokens !== null && waited && WHOLE_FADE_CLASS)}
187
+ aria-hidden={pending || undefined}
188
+ style={{ color: tokenizer?.fg }}
189
+ >
163
190
  <code>
164
- {tokens.map((line, index) => (
165
- // Lines only ever grow at the end, so the place of a line is a stable key.
166
- <Fragment key={index}>
167
- <CodeLine tokens={line} />
168
- {index < tokens.length - 1 && '\n'}
169
- </Fragment>
170
- ))}
191
+ {tokens === null
192
+ ? code
193
+ : tokens.map((line, index) => (
194
+ // Lines only ever grow at the end, so the place of a line is a stable key.
195
+ <Fragment key={index}>
196
+ <CodeLine tokens={line} />
197
+ {index < tokens.length - 1 && '\n'}
198
+ </Fragment>
199
+ ))}
171
200
  </code>
172
201
  </pre>
202
+ <ButtonGroup className="chat-code-actions" role="group" aria-label={t('code.actions')}>
203
+ <IconButton
204
+ icon={copyState === 'copied' ? Check : Copy}
205
+ size="sm"
206
+ label={copyState === 'copied' ? t('timeline.actions.copied') : t('timeline.menu.copyCode')}
207
+ busy={copyState === 'copying'}
208
+ aria-busy={copyState === 'copying'}
209
+ aria-disabled={copyState === 'copying'}
210
+ onClick={() => void copy()}
211
+ />
212
+ {actions}
213
+ </ButtonGroup>
214
+ <span className="sr-only" role="status">
215
+ {copyState === 'copied' ? t('timeline.actions.copied') : ''}
216
+ </span>
217
+ {copyState === 'failed' && (
218
+ <p className="chat-code-error" role="alert">
219
+ {t('code.copyFailed')}
220
+ </p>
221
+ )}
173
222
  </div>
174
223
  );
175
224
  }
@@ -166,6 +166,8 @@ function splitPath(path: string): { name: string; dir: string } {
166
166
  // Keep the editor mounted while a pending request takes over, so its selection and draft survive.
167
167
  export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }: ComposerProps) {
168
168
  const { t } = useTranslation('agent-chat');
169
+ const sendDelivery = chatHost().useSendDelivery();
170
+ const canSteer = info.provider === 'claude' || info.provider === 'codex';
169
171
  const [draft, setDraft] = useState<ChatDraft>(() => readDraft(chatId));
170
172
  const [historyIndex, setHistoryIndex] = useState<number | null>(null);
171
173
  const [menuIndex, setMenuIndex] = useState(0);
@@ -602,7 +604,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
602
604
  setDraft((current) => ({ ...current, attachments: current.attachments.filter((_, i) => i !== index) }));
603
605
  };
604
606
 
605
- const submit = (): void => {
607
+ const submit = (alternate = false): void => {
608
+ const delivery = canSteer && (alternate ? sendDelivery === 'queue' : sendDelivery === 'steer') ? 'steer' : 'queue';
606
609
  const trimmed = text.trim();
607
610
  if (isEmptyDraft(draft) || !writable || guard.tooLong) {
608
611
  return;
@@ -633,6 +636,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
633
636
  return;
634
637
  }
635
638
  onSend(withQuote(quote, trimmed), {
639
+ delivery,
636
640
  mentions: presentMentions(trimmed, draft.mentions),
637
641
  skills: presentSkills(trimmed, draft.skills),
638
642
  chats: draft.chats,
@@ -871,7 +875,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
871
875
  view.dispatch({ changes: { from: line.from, to: line.to }, selection: { anchor: line.from }, userEvent: 'delete' });
872
876
  return true;
873
877
  }
874
- submit();
878
+ submit(e.altKey);
875
879
  return true;
876
880
  }
877
881
  const { from, to } = view.state.selection.main;
@@ -1295,13 +1299,26 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1295
1299
  </button>
1296
1300
  </Tooltip>
1297
1301
  )}
1298
- {/* While a turn runs the same button queues the message instead of sending it. */}
1299
1302
  {(!busy || !isEmptyDraft(draft)) && (
1300
- <Tooltip label={busy ? t('composer.queueButton') : t('composer.send')} kbd={KEY_SHORTCUTS.modEnter} name>
1303
+ <Tooltip
1304
+ label={
1305
+ busy
1306
+ ? t(
1307
+ canSteer
1308
+ ? sendDelivery === 'steer'
1309
+ ? 'composer.steerButton'
1310
+ : 'composer.queueWithSteerButton'
1311
+ : 'composer.queueButton'
1312
+ )
1313
+ : t('composer.send')
1314
+ }
1315
+ kbd={KEY_SHORTCUTS.modEnter}
1316
+ name
1317
+ >
1301
1318
  <button
1302
1319
  className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100"
1303
1320
  disabled={isEmptyDraft(draft) || !writable || guard.tooLong}
1304
- onClick={submit}
1321
+ onClick={(event) => submit(event.altKey)}
1305
1322
  >
1306
1323
  <Icon icon={ArrowUp} size={16} />
1307
1324
  </button>
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useRef, useState, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
4
  import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
@@ -34,7 +34,19 @@ function zoomed(view: View, factor: number, pointX: number, pointY: number): Vie
34
34
  }
35
35
 
36
36
  /* An image on its own, large: the wheel and the buttons zoom, dragging pans, Escape closes. */
37
- function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string; open: boolean; onOpenChange(open: boolean): void }) {
37
+ function Lightbox({
38
+ src,
39
+ alt,
40
+ open,
41
+ onOpenChange,
42
+ actions
43
+ }: {
44
+ src: string;
45
+ alt: string;
46
+ open: boolean;
47
+ onOpenChange(open: boolean): void;
48
+ actions?: ReactNode;
49
+ }) {
38
50
  const { t } = useTranslation('agent-chat');
39
51
  const [view, setView] = useState<View>(START);
40
52
  // The alt of an image an agent read is its path; the header shows the file and keeps the path on a tooltip.
@@ -84,6 +96,7 @@ function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string;
84
96
  />
85
97
  <IconButton icon={RotateCcw} size="sm" label={t('image.reset')} disabled={view.scale === MIN_SCALE} onClick={() => setView(START)} />
86
98
  </ButtonGroup>
99
+ {actions}
87
100
  <CloseButton label={t('common.action.close')} kbd="esc" className="ml-1" dialog />
88
101
  </div>
89
102
  <div
@@ -130,16 +143,41 @@ function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string;
130
143
  );
131
144
  }
132
145
 
146
+ export interface ImageThumbProps {
147
+ source: ResourceUrl;
148
+ alt: string;
149
+ className?: string;
150
+ /* Width over height, known before the bytes: the thumbnail then fills its container's width at that ratio and holds its place while they load. */
151
+ aspect?: number;
152
+ /* Drawn in the lightbox's header before Close, such as icon buttons that act on the image. */
153
+ actions?: ReactNode;
154
+ /* Whether the lightbox is open, for a caller that opens it from a control of its own; left out, the thumbnail keeps it. */
155
+ open?: boolean;
156
+ onOpenChange?(open: boolean): void;
157
+ }
158
+
133
159
  /*
134
160
  * A thumbnail that opens the same image large. The button is the picture, so there is nothing to aim at.
135
161
  * The image is bytes where the host keeps them; until they are here a quiet square stands in for it.
136
162
  */
137
- export function ImageThumb({ source, alt, className }: { source: ResourceUrl; alt: string; className?: string }) {
138
- const [open, setOpen] = useState(false);
163
+ export function ImageThumb({ source, alt, className, aspect, actions, open: openProp, onOpenChange }: ImageThumbProps) {
164
+ const [ownOpen, setOwnOpen] = useState(false);
165
+ const open = openProp ?? ownOpen;
166
+ const setOpen = (next: boolean): void => {
167
+ setOwnOpen(next);
168
+ onOpenChange?.(next);
169
+ };
139
170
  const { url: src, failure } = source;
171
+ const ratio = aspect === undefined ? undefined : { aspectRatio: aspect };
140
172
  if (src === null) {
141
173
  return (
142
- <span className="grid size-16 place-items-center rounded-lg border border-border bg-surface-sunken text-text-faint">
174
+ <span
175
+ className={clsx(
176
+ 'grid place-items-center rounded-lg border border-border bg-surface-sunken text-text-faint',
177
+ aspect === undefined ? 'size-16' : 'w-full'
178
+ )}
179
+ style={ratio}
180
+ >
143
181
  {failure !== null && (
144
182
  <Tooltip label={failure}>
145
183
  <Icon icon={ImageOff} size={14} />
@@ -150,10 +188,15 @@ export function ImageThumb({ source, alt, className }: { source: ResourceUrl; al
150
188
  }
151
189
  return (
152
190
  <>
153
- <button className="block overflow-hidden rounded-lg border border-border" onClick={() => setOpen(true)}>
154
- <img src={src} alt={alt} className={className} />
191
+ <button
192
+ type="button"
193
+ className={clsx('block overflow-hidden rounded-lg border border-border', aspect !== undefined && 'w-full')}
194
+ style={ratio}
195
+ onClick={() => setOpen(true)}
196
+ >
197
+ <img src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
155
198
  </button>
156
- <Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} />
199
+ <Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} actions={actions} />
157
200
  </>
158
201
  );
159
202
  }
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Bot, LoaderCircle } from 'lucide-react';
4
4
  import { deriveTimelineRows } from '../logic/timeline';
@@ -9,6 +9,8 @@ import { EMPTY_TARGET, readTimelineTarget, type TimelineTarget } from '../logic/
9
9
  import { openBelow, useSubagentTrail, type SubagentStep } from '../subagent-view';
10
10
  import { Row } from './rows/Rows';
11
11
  import { ReplyContext } from './reply-context';
12
+ import { PromptComposer } from './PromptComposer';
13
+ import { useProviders } from '../../state/providers';
12
14
  import { SubagentInfo } from './SubagentInfo';
13
15
  import { TimelineMenuPopup } from './TimelineMenu';
14
16
  import { FOLLOW_THRESHOLD_PX, rowRhythm } from './rows/row-rhythm';
@@ -27,8 +29,14 @@ const NO_TURNS = new Set<string>();
27
29
  export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
28
30
  const { t } = useTranslation('agent-chat');
29
31
  const scope = useChatScope();
32
+ const connected = useSyncExternalStore(
33
+ useCallback((changed) => scope.transport.subscribeStatus(changed), [scope]),
34
+ useCallback(() => scope.transport.status === 'open', [scope]),
35
+ () => false
36
+ );
30
37
  const { trail, show } = useSubagentTrail(chatId);
31
38
  const [state, setState] = useState<SubagentConversationState>(INITIAL_CONVERSATION);
39
+ const denyReason = useProviders((row) => row.providers.find((provider) => provider.kind === state.context?.provider)?.capabilities.denyReason === true);
32
40
  const controller = useRef<SubagentConversation | null>(null);
33
41
  const scrollRef = useRef<HTMLDivElement>(null);
34
42
  const threadRef = useRef<HTMLDivElement>(null);
@@ -117,6 +125,8 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
117
125
  <ReplyContext.Provider value={state.context ?? { provider: state.source === 'codex-thread' ? 'codex' : 'claude' }}>
118
126
  <div
119
127
  ref={scrollRef}
128
+ data-file-cwd={state.context?.cwd ?? cwd ?? ''}
129
+ data-file-scope-id={scope.id}
120
130
  className="chat-thread relative min-h-0 grow overflow-auto px-4 pt-4 pb-3"
121
131
  // The thread keeps its reader in place itself when a page goes in above.
122
132
  style={{ overflowAnchor: 'none' }}
@@ -165,6 +175,21 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
165
175
  </ReplyContext.Provider>
166
176
  </FileLinkContext.Provider>
167
177
  )}
178
+ {state.context?.chatId !== undefined && state.pending.length > 0 && (
179
+ <div className="chat-column-content shrink-0">
180
+ <PromptComposer
181
+ key={state.context.chatId}
182
+ chatId={state.context.chatId}
183
+ pending={state.pending}
184
+ focused={false}
185
+ disabled={!connected}
186
+ hasDraft={false}
187
+ denyReason={denyReason}
188
+ >
189
+ {null}
190
+ </PromptComposer>
191
+ </div>
192
+ )}
168
193
  </div>
169
194
  );
170
195
  }