@adecore/agents-react 0.41.1 → 0.42.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 (34) hide show
  1. package/dist/chat/actions.js +4 -0
  2. package/dist/chat/logic/lightbox.d.ts +31 -0
  3. package/dist/chat/logic/lightbox.js +61 -0
  4. package/dist/chat/logic/timeline.d.ts +1 -0
  5. package/dist/chat/logic/timeline.js +3 -0
  6. package/dist/chat/ui/AnsiOutput.d.ts +3 -2
  7. package/dist/chat/ui/AnsiOutput.js +26 -22
  8. package/dist/chat/ui/ImageView.d.ts +3 -0
  9. package/dist/chat/ui/ImageView.js +761 -437
  10. package/dist/chat/ui/intelligent-ui/renderers/content.js +4 -2
  11. package/dist/chat/ui/rows/MessageRows.js +10 -8
  12. package/dist/chat/ui/rows/Rows.js +42 -39
  13. package/dist/chat/ui/rows/WorkRows.d.ts +4 -2
  14. package/dist/chat/ui/rows/WorkRows.js +580 -211
  15. package/dist/chat/ui/rows/WorkflowRow.js +43 -41
  16. package/dist/host.d.ts +1 -0
  17. package/dist/locales/en/agent-chat.d.ts +9 -0
  18. package/dist/locales/en/agent-chat.js +11 -2
  19. package/dist/locales/nl/agent-chat.d.ts +9 -0
  20. package/dist/locales/nl/agent-chat.js +11 -2
  21. package/package.json +8 -8
  22. package/src/chat/actions.ts +1 -0
  23. package/src/chat/logic/lightbox.ts +88 -0
  24. package/src/chat/logic/timeline.ts +13 -2
  25. package/src/chat/ui/AnsiOutput.tsx +13 -2
  26. package/src/chat/ui/ImageView.tsx +255 -63
  27. package/src/chat/ui/intelligent-ui/renderers/content.tsx +4 -2
  28. package/src/chat/ui/rows/MessageRows.tsx +10 -8
  29. package/src/chat/ui/rows/Rows.tsx +9 -1
  30. package/src/chat/ui/rows/WorkRows.tsx +127 -27
  31. package/src/chat/ui/rows/WorkflowRow.tsx +1 -1
  32. package/src/host.ts +2 -0
  33. package/src/locales/en/agent-chat.ts +11 -2
  34. package/src/locales/nl/agent-chat.ts +11 -2
@@ -1,90 +1,226 @@
1
- import { useRef, useState, type ReactNode } from 'react';
1
+ import { createContext, useContext, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
- import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
4
+ import { ChevronLeft, ChevronRight, ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
5
5
  import type { ResourceUrl } from '../../host';
6
6
  import { ButtonGroup, CloseButton, Dialog, Icon, IconButton, Tooltip } from '@adecore/ui';
7
+ import { formatNumber } from '@adecore/ui/format';
8
+ import { BUTTON_STEP, clamped, MAX_SCALE, MIN_SCALE, START, stepped, wheeled, zoomed, type Bounds, type Point, type View } from '../logic/lightbox';
7
9
  import { DisplayImage } from './DisplayImage';
8
10
 
9
- const MIN_SCALE = 1;
10
- const MAX_SCALE = 8;
11
- // One wheel notch, small enough that a trackpad flick does not jump past the detail it aimed at.
12
- const WHEEL_STEP = 1.0015;
13
- const BUTTON_STEP = 1.5;
11
+ interface LightboxImage {
12
+ readonly key: string;
13
+ readonly src: string;
14
+ readonly alt: string;
15
+ readonly element: HTMLElement | null;
16
+ }
14
17
 
15
- interface View {
16
- scale: number;
17
- x: number;
18
- y: number;
18
+ interface ImageGroupStore {
19
+ set(image: LightboxImage): void;
20
+ remove(key: string): void;
21
+ subscribe(listener: () => void): () => void;
22
+ snapshot(): readonly LightboxImage[];
19
23
  }
20
24
 
21
- const START: View = { scale: 1, x: 0, y: 0 };
25
+ /* The pictures of one group in the order they stand on the page, which is the order the arrows walk. */
26
+ function createImageGroupStore(): ImageGroupStore {
27
+ const images = new Map<string, LightboxImage>();
28
+ const listeners = new Set<() => void>();
29
+ let snapshot: readonly LightboxImage[] = [];
30
+
31
+ const changed = (): void => {
32
+ snapshot = [...images.values()].sort((a, b) => {
33
+ if (a.element === null || b.element === null) {
34
+ return 0;
35
+ }
36
+
37
+ return a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
38
+ });
39
+
40
+ for (const listener of listeners) {
41
+ listener();
42
+ }
43
+ };
44
+
45
+ return {
46
+ set(image) {
47
+ const current = images.get(image.key);
48
+
49
+ if (current?.src === image.src && current.alt === image.alt && current.element === image.element) {
50
+ return;
51
+ }
52
+
53
+ images.set(image.key, image);
54
+ changed();
55
+ },
56
+ remove(key) {
57
+ if (images.delete(key)) {
58
+ changed();
59
+ }
60
+ },
61
+ subscribe(listener) {
62
+ listeners.add(listener);
63
+
64
+ return () => {
65
+ listeners.delete(listener);
66
+ };
67
+ },
68
+ snapshot: () => snapshot
69
+ };
70
+ }
71
+
72
+ interface ImageGroupValue {
73
+ readonly store: ImageGroupStore;
74
+ open(key: string): void;
75
+ }
76
+
77
+ const ImageGroupContext = createContext<ImageGroupValue | null>(null);
22
78
 
23
79
  /*
24
- * Zooms around a point, which is what makes a wheel over a detail feel like a magnifier instead of
25
- * a slider. The pixel under the pointer stays where it is while everything else moves away from it.
26
- * `x` and `y` are offsets from the middle of the frame, so a scale of 1 is always centered.
80
+ * Thumbnails inside open one lightbox together, where the arrow keys walk from one to the next.
81
+ * A thumbnail with actions of its own, or one its caller opens from a control, stays on its own.
27
82
  */
28
- function zoomed(view: View, factor: number, pointX: number, pointY: number): View {
29
- const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, view.scale * factor));
83
+ export function ImageGroup({ children }: { children: ReactNode }) {
84
+ const [store] = useState(createImageGroupStore);
85
+ const images = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
86
+ // The key stays after closing, so the picture that fades out is the one that was open.
87
+ const [shown, setShown] = useState<{ key: string | null; open: boolean }>({ key: null, open: false });
88
+ const index = images.findIndex((image) => image.key === shown.key);
30
89
 
31
- if (scale === MIN_SCALE) {
32
- return START;
33
- }
90
+ return (
91
+ <ImageGroupContext value={{ store, open: (key) => setShown({ key, open: true }) }}>
92
+ {children}
93
+ {index !== -1 && (
94
+ <Lightbox
95
+ images={images}
96
+ index={index}
97
+ open={shown.open}
98
+ onOpenChange={(open) => setShown((current) => ({ ...current, open }))}
99
+ onIndexChange={(next) => setShown({ key: images[next]?.key ?? null, open: true })}
100
+ />
101
+ )}
102
+ </ImageGroupContext>
103
+ );
104
+ }
34
105
 
35
- const ratio = scale / view.scale;
106
+ /* A press on an arrow or a zoom button is the button's, never the start of a pan or a zoom of the stage. */
107
+ function onControl(target: EventTarget): boolean {
108
+ return target instanceof Element && target.closest('button') !== null;
109
+ }
36
110
 
37
- return { scale, x: pointX - (pointX - view.x) * ratio, y: pointY - (pointY - view.y) * ratio };
111
+ /* Where the pointer sits against the middle of the stage, which is what the transform counts from. */
112
+ function pointIn(stage: HTMLElement, event: { clientX: number; clientY: number }): Point {
113
+ const rect = stage.getBoundingClientRect();
114
+
115
+ return { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 };
38
116
  }
39
117
 
40
- /* An image on its own, large: the wheel and the buttons zoom, dragging pans, Escape closes. */
118
+ function boundsOf(stage: HTMLElement): Bounds {
119
+ const picture = stage.querySelector('img');
120
+
121
+ return {
122
+ image: { width: picture?.offsetWidth ?? 0, height: picture?.offsetHeight ?? 0 },
123
+ frame: { width: stage.clientWidth, height: stage.clientHeight }
124
+ };
125
+ }
126
+
127
+ /* Pictures large and on their own: a pinch zooms, two fingers pan, the arrow keys walk the group, Escape closes. */
41
128
  function Lightbox({
42
- src,
43
- alt,
129
+ images,
130
+ index,
44
131
  open,
45
132
  onOpenChange,
133
+ onIndexChange,
46
134
  actions
47
135
  }: {
48
- src: string;
49
- alt: string;
136
+ images: readonly LightboxImage[];
137
+ index: number;
50
138
  open: boolean;
51
139
  onOpenChange(open: boolean): void;
140
+ onIndexChange?(index: number): void;
52
141
  actions?: ReactNode;
53
142
  }) {
54
143
  const { t } = useTranslation('agent-chat');
55
144
  const [view, setView] = useState<View>(START);
56
- // The alt of an image an agent read is its path; the header shows the file and keeps the path on a tooltip.
57
- const name = alt.slice(alt.lastIndexOf('/') + 1);
58
- const title = <span className="min-w-0 truncate text-xs text-text">{name}</span>;
59
- const frameRef = useRef<HTMLDivElement>(null);
145
+ const [viewed, setViewed] = useState(index);
146
+ const stageElementRef = useRef<HTMLDivElement | null>(null);
60
147
  const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null);
148
+ const movedRef = useRef(false);
149
+ const image = images[index];
150
+ const previous = stepped(index, images.length, -1);
151
+ const next = stepped(index, images.length, 1);
152
+
153
+ if (viewed !== index) {
154
+ setViewed(index);
155
+ setView(START);
156
+ }
61
157
 
62
158
  if (!open && view !== START) {
63
159
  setView(START);
64
160
  }
65
161
 
66
- /* Where the pointer sits against the middle of the frame, which is what the transform counts from. */
67
- const pointIn = (event: { clientX: number; clientY: number }): { x: number; y: number } => {
68
- const rect = frameRef.current?.getBoundingClientRect();
162
+ if (image === undefined) {
163
+ return null;
164
+ }
165
+
166
+ // The alt of an image an agent read is its path; the caption shows the file and keeps the path on a tooltip.
167
+ const name = image.alt.slice(image.alt.lastIndexOf('/') + 1);
168
+ const title = <span className="min-w-0 truncate text-xs text-text">{name}</span>;
169
+
170
+ // React listens to the wheel passively, and only an active listener keeps a pinch from zooming the whole page.
171
+ const stageRef = (stage: HTMLDivElement | null) => {
172
+ stageElementRef.current = stage;
69
173
 
70
- if (!rect) {
71
- return { x: 0, y: 0 };
174
+ if (stage === null) {
175
+ return;
72
176
  }
73
177
 
74
- return { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 };
178
+ const onWheel = (event: WheelEvent): void => {
179
+ event.preventDefault();
180
+ const point = pointIn(stage, event);
181
+ const bounds = boundsOf(stage);
182
+ setView((current) => wheeled(current, event, point, bounds));
183
+ };
184
+
185
+ stage.addEventListener('wheel', onWheel, { passive: false });
186
+
187
+ return () => stage.removeEventListener('wheel', onWheel);
75
188
  };
76
189
 
77
- const zoomButton = (factor: number): void => setView((current) => zoomed(current, factor, 0, 0));
190
+ const zoomButton = (factor: number): void => {
191
+ const stage = stageElementRef.current;
192
+
193
+ if (stage !== null) {
194
+ const bounds = boundsOf(stage);
195
+ setView((current) => zoomed(current, factor, { x: 0, y: 0 }, bounds));
196
+ }
197
+ };
198
+
199
+ const step = (to: number | null): void => {
200
+ if (to !== null) {
201
+ onIndexChange?.(to);
202
+ }
203
+ };
204
+
205
+ const onKeyDown = (event: KeyboardEvent): void => {
206
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
207
+ return;
208
+ }
209
+
210
+ const to = event.key === 'ArrowLeft' ? previous : event.key === 'ArrowRight' ? next : null;
211
+
212
+ if (to !== null) {
213
+ event.preventDefault();
214
+ step(to);
215
+ }
216
+ };
78
217
 
79
218
  return (
80
219
  <Dialog.Root open={open} onOpenChange={onOpenChange}>
81
- {/* Nothing here sets a size. The picture takes the room `.lightbox-frame` allows it, and the popup follows. */}
82
- <Dialog.Popup backdropClassName="lightbox-backdrop" className="flex min-w-72 flex-col">
83
- <Dialog.Title className="sr-only">{alt}</Dialog.Title>
84
- <div className="flex h-11 shrink-0 items-center gap-2 border-b border-border pr-2 pl-3">
85
- {name === alt ? title : <Tooltip label={alt}>{title}</Tooltip>}
86
- <span className="grow" />
87
- <span className="tabular-nums text-xs text-text-faint">{Math.round(view.scale * 100)}%</span>
220
+ <Dialog.Popup backdropClassName="lightbox-backdrop" className="lightbox" onKeyDown={onKeyDown}>
221
+ <Dialog.Title className="sr-only">{image.alt}</Dialog.Title>
222
+ <div className="lightbox-bar flex h-12 shrink-0 items-center justify-end gap-2 pl-3">
223
+ <span className="tabular-nums text-xs text-text-faint">{formatNumber(view.scale * 100)}%</span>
88
224
  <ButtonGroup render={<span />}>
89
225
  <IconButton
90
226
  icon={Minus}
@@ -106,18 +242,21 @@ function Lightbox({
106
242
  <CloseButton label={t('common.action.close')} kbd="esc" className="ml-1" dialog />
107
243
  </div>
108
244
  <div
109
- ref={frameRef}
110
- className={clsx('lightbox-frame grid place-items-center overflow-hidden bg-surface-sunken', view.scale > MIN_SCALE && 'cursor-grab')}
111
- onWheel={(e) => {
112
- const point = pointIn(e);
113
- setView((current) => zoomed(current, WHEEL_STEP ** -e.deltaY, point.x, point.y));
114
- }}
245
+ ref={stageRef}
246
+ className={clsx('lightbox-stage relative grid min-h-0 grow place-items-center overflow-hidden', view.scale > MIN_SCALE && 'cursor-grab')}
115
247
  onDoubleClick={(e) => {
116
- const point = pointIn(e);
117
- setView((current) => (current.scale > MIN_SCALE ? START : zoomed(current, 2, point.x, point.y)));
248
+ if (onControl(e.target)) {
249
+ return;
250
+ }
251
+
252
+ const point = pointIn(e.currentTarget, e);
253
+ const bounds = boundsOf(e.currentTarget);
254
+ setView((current) => (current.scale > MIN_SCALE ? START : zoomed(current, 2, point, bounds)));
118
255
  }}
119
256
  onPointerDown={(e) => {
120
- if (view.scale <= MIN_SCALE) {
257
+ movedRef.current = false;
258
+
259
+ if (view.scale <= MIN_SCALE || onControl(e.target)) {
121
260
  return;
122
261
  }
123
262
 
@@ -127,9 +266,13 @@ function Lightbox({
127
266
  onPointerMove={(e) => {
128
267
  const drag = dragRef.current;
129
268
 
130
- if (drag?.pointerId === e.pointerId) {
131
- setView((current) => ({ ...current, x: e.clientX - drag.x, y: e.clientY - drag.y }));
269
+ if (drag?.pointerId !== e.pointerId) {
270
+ return;
132
271
  }
272
+
273
+ movedRef.current = true;
274
+ const bounds = boundsOf(e.currentTarget);
275
+ setView((current) => clamped({ ...current, x: e.clientX - drag.x, y: e.clientY - drag.y }, bounds));
133
276
  }}
134
277
  onPointerUp={() => {
135
278
  dragRef.current = null;
@@ -137,14 +280,49 @@ function Lightbox({
137
280
  onPointerCancel={() => {
138
281
  dragRef.current = null;
139
282
  }}
283
+ onClick={(e) => {
284
+ // The room around the picture is the backdrop a dialog would have, unless the press panned.
285
+ if (e.target === e.currentTarget && !movedRef.current) {
286
+ onOpenChange(false);
287
+ }
288
+ }}
140
289
  >
141
290
  <DisplayImage
142
- src={src}
143
- alt={alt}
291
+ key={image.key}
292
+ src={image.src}
293
+ alt={image.alt}
144
294
  draggable={false}
145
295
  className="select-none"
146
296
  style={{ transform: `translate(${Math.round(view.x)}px, ${Math.round(view.y)}px) scale(${view.scale})` }}
147
297
  />
298
+ {images.length > 1 && (
299
+ <>
300
+ <IconButton
301
+ icon={ChevronLeft}
302
+ label={t('image.previous')}
303
+ kbd="←"
304
+ disabled={previous === null}
305
+ className="absolute top-1/2 left-4 -translate-y-1/2"
306
+ onClick={() => step(previous)}
307
+ />
308
+ <IconButton
309
+ icon={ChevronRight}
310
+ label={t('image.next')}
311
+ kbd="→"
312
+ disabled={next === null}
313
+ className="absolute top-1/2 right-4 -translate-y-1/2"
314
+ onClick={() => step(next)}
315
+ />
316
+ </>
317
+ )}
318
+ </div>
319
+ <div className="flex h-12 shrink-0 items-center justify-center gap-3 px-3">
320
+ {name === image.alt ? title : <Tooltip label={image.alt}>{title}</Tooltip>}
321
+ {images.length > 1 && (
322
+ <span className="shrink-0 tabular-nums text-xs text-text-faint">
323
+ {t('image.position', { index: formatNumber(index + 1), count: formatNumber(images.length) })}
324
+ </span>
325
+ )}
148
326
  </div>
149
327
  </Dialog.Popup>
150
328
  </Dialog.Root>
@@ -169,17 +347,30 @@ export interface ImageThumbProps {
169
347
  * The image is bytes where the host keeps them; until they are here a quiet square stands in for it.
170
348
  */
171
349
  export function ImageThumb({ source, alt, className, aspect, actions, open: openProp, onOpenChange }: ImageThumbProps) {
350
+ const key = useId();
351
+ const buttonRef = useRef<HTMLButtonElement>(null);
352
+ const context = useContext(ImageGroupContext);
353
+ const group = actions === undefined && openProp === undefined ? context : null;
172
354
  const [ownOpen, setOwnOpen] = useState(false);
173
355
  const open = openProp ?? ownOpen;
356
+ const { url: src, failure } = source;
357
+ const ratio = aspect === undefined ? undefined : { aspectRatio: aspect };
358
+
359
+ useLayoutEffect(() => {
360
+ if (group === null || src === null) {
361
+ return;
362
+ }
363
+
364
+ group.store.set({ key, src, alt, element: buttonRef.current });
365
+
366
+ return () => group.store.remove(key);
367
+ }, [group, key, src, alt]);
174
368
 
175
369
  const setOpen = (next: boolean): void => {
176
370
  setOwnOpen(next);
177
371
  onOpenChange?.(next);
178
372
  };
179
373
 
180
- const { url: src, failure } = source;
181
- const ratio = aspect === undefined ? undefined : { aspectRatio: aspect };
182
-
183
374
  if (src === null) {
184
375
  return (
185
376
  <span
@@ -201,14 +392,15 @@ export function ImageThumb({ source, alt, className, aspect, actions, open: open
201
392
  return (
202
393
  <>
203
394
  <button
395
+ ref={buttonRef}
204
396
  type="button"
205
397
  className={clsx('block overflow-hidden rounded-lg border border-border', aspect !== undefined && 'w-full')}
206
398
  style={ratio}
207
- onClick={() => setOpen(true)}
399
+ onClick={() => (group === null ? setOpen(true) : group.open(key))}
208
400
  >
209
401
  <DisplayImage src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
210
402
  </button>
211
- <Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} actions={actions} />
403
+ {group === null && <Lightbox images={[{ key, src, alt, element: null }]} index={0} open={open} onOpenChange={setOpen} actions={actions} />}
212
404
  </>
213
405
  );
214
406
  }
@@ -8,7 +8,7 @@ import { formatNumber } from '@adecore/ui/format';
8
8
  import { useChatHost } from '../../../../host';
9
9
  import { CodeBlock } from '../../CodeBlock';
10
10
  import { CodeStreamingContext } from '../../code-streaming';
11
- import { ImageThumb } from '../../ImageView';
11
+ import { ImageGroup, ImageThumb } from '../../ImageView';
12
12
  import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text';
13
13
  import type { UiRendererProps } from '../render-context';
14
14
  import { useTruncated } from '../use-truncated';
@@ -105,7 +105,9 @@ export function GalleryRenderer({ node, children }: UiRendererProps<UiProps<'Gal
105
105
  return (
106
106
  <GalleryContext value>
107
107
  <div role="group" aria-label={t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length })} className="@container">
108
- <div className="chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3">{children}</div>
108
+ <div className="chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3">
109
+ <ImageGroup>{children}</ImageGroup>
110
+ </div>
109
111
  </div>
110
112
  </GalleryContext>
111
113
  );
@@ -22,7 +22,7 @@ import { referenceTokens } from '../../chat-references';
22
22
  import { presentReferences } from '../../mentions';
23
23
  import { ReferenceChip } from '../ReferenceChip';
24
24
  import { ReferenceLookupContext } from '../reference-lookup';
25
- import { ImageThumb } from '../ImageView';
25
+ import { ImageGroup, ImageThumb } from '../ImageView';
26
26
  import { Markdown, MessageMarkdown, ReplyMarkdown } from '../Markdown';
27
27
  import { FadingWords } from '../FadingWords';
28
28
  import { settledBlocksText } from '../markdown-blocks';
@@ -123,13 +123,15 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
123
123
  <MessageHeading>{t('rows.user.heading')}</MessageHeading>
124
124
  {attachments.length > 0 && (
125
125
  <div className="mb-1.5 flex max-w-[80%] flex-wrap justify-end gap-1.5">
126
- {attachments.map((attachment) =>
127
- isImageAttachment(attachment.mime) ? (
128
- <AttachmentThumb key={attachment.id} chatId={chatId} attachment={attachment} />
129
- ) : (
130
- <AttachmentLink key={attachment.id} chatId={chatId} attachment={attachment} />
131
- )
132
- )}
126
+ <ImageGroup>
127
+ {attachments.map((attachment) =>
128
+ isImageAttachment(attachment.mime) ? (
129
+ <AttachmentThumb key={attachment.id} chatId={chatId} attachment={attachment} />
130
+ ) : (
131
+ <AttachmentLink key={attachment.id} chatId={chatId} attachment={attachment} />
132
+ )
133
+ )}
134
+ </ImageGroup>
133
135
  </div>
134
136
  )}
135
137
  {beside.length > 0 && (
@@ -83,7 +83,15 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
83
83
  return <ForksRow chatId={chatId} turnId={row.turnId} />;
84
84
  case 'changed-files':
85
85
  return (
86
- <ChangedFilesRow chatId={chatId} turnId={row.turnId} tools={row.tools} diff={row.diff} checkpoint={row.checkpoint} baseMoved={row.baseMoved} />
86
+ <ChangedFilesRow
87
+ chatId={chatId}
88
+ turnId={row.turnId}
89
+ tools={row.tools}
90
+ diff={row.diff}
91
+ checkpoint={row.checkpoint}
92
+ baseMoved={row.baseMoved}
93
+ heldBack={row.heldBack === true}
94
+ />
87
95
  );
88
96
  case 'approval':
89
97
  return <ApprovalHistoryRow item={row.item} />;