@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
@@ -26,11 +26,12 @@ import { useChatHost, type CommandLook } from '../../../host';
26
26
  import { useCommandNames } from '../../../host-hooks';
27
27
  import { lazyNamed } from '../../../lazy';
28
28
  import { useChatActions } from '../../actions';
29
+ import { formatBytes } from '../../attachments';
29
30
  import { ChatScopeContext, useChatScope } from '../../../scope';
30
31
  import { useAwaitsApproval, useCurrentItem } from '../../../state/chats';
31
32
  import { useTimelineFlash } from '../../timeline-flash';
32
33
  import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
33
- import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
34
+ import { Button, Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
34
35
  import { ROW_GUTTER, toolIcon } from '../icons';
35
36
  import { AnsiOutput } from '../AnsiOutput';
36
37
  import { RowTime } from '../RowTime';
@@ -251,36 +252,119 @@ function ChangedFileLine({
251
252
  );
252
253
  }
253
254
 
255
+ type WholeState<T> = { status: 'idle' } | { status: 'loading' } | { status: 'loaded'; value: T } | { status: 'failed' };
256
+
257
+ interface Whole<T> {
258
+ state: WholeState<T>;
259
+ /* Null where nothing can load it. */
260
+ load: (() => void) | null;
261
+ }
262
+
263
+ /* The whole of what the thread keeps only the beginning of, loaded once a person asks; a failure can be tried again. */
264
+ function useWhole<T>(fetch: (() => Promise<T>) | null): Whole<T> {
265
+ const [state, setState] = useState<WholeState<T>>({ status: 'idle' });
266
+
267
+ if (fetch === null) {
268
+ return { state, load: null };
269
+ }
270
+
271
+ const load = (): void => {
272
+ setState({ status: 'loading' });
273
+
274
+ fetch().then(
275
+ (value) => setState({ status: 'loaded', value }),
276
+ () => setState({ status: 'failed' })
277
+ );
278
+ };
279
+
280
+ return { state, load };
281
+ }
282
+
283
+ /* The line under a beginning that offers the rest: its size when known, and a retry after a failure. */
284
+ function WholeBar<T>({ whole, label, failedLabel, bytes }: { whole: Whole<T>; label: string; failedLabel: string; bytes?: number | undefined }) {
285
+ const { t } = useTranslation('agent-chat');
286
+ const { state, load } = whole;
287
+
288
+ if (load === null || state.status === 'loaded') {
289
+ return null;
290
+ }
291
+
292
+ const failed = state.status === 'failed';
293
+ const loading = state.status === 'loading';
294
+
295
+ return (
296
+ <div className="flex items-center gap-2 py-1.5 font-sans text-xs text-text-faint">
297
+ {failed && <span className="text-status-error">{failedLabel}</span>}
298
+ {!failed && bytes !== undefined && <span>{t('work.wholeSize', { size: formatBytes(bytes) })}</span>}
299
+ <Button size="xs" variant="secondary" disabled={loading} onClick={load}>
300
+ {loading && <Spinner size={12} />}
301
+ {failed ? t('work.retry') : label}
302
+ </Button>
303
+ </div>
304
+ );
305
+ }
306
+
307
+ /* What a call answered as far as the thread shows it, and the whole once a person asks: from the host when the item carries only the beginning. */
308
+ function useToolText(tool: ChatToolItem, chatId: string | undefined): { text: string | null; whole: Whole<string | null> } {
309
+ const { toolOutput } = useChatActions();
310
+ const stored = tool.outputRef !== undefined;
311
+ let fetch: (() => Promise<string | null>) | null = null;
312
+
313
+ if (stored && chatId !== undefined && toolOutput !== undefined) {
314
+ fetch = () => toolOutput(chatId, tool.id);
315
+ } else if (!stored && (tool.output?.length ?? 0) > OUTPUT_LIMIT) {
316
+ fetch = () => Promise.resolve(tool.output);
317
+ }
318
+
319
+ const whole = useWhole(fetch);
320
+
321
+ return { text: whole.state.status === 'loaded' ? whole.state.value : tool.output, whole };
322
+ }
323
+
324
+ /* An output clipped while it is the beginning and whole once loaded. A beginning the host keeps the rest of counts no characters it cannot see. */
325
+ function OutputText({ text, whole, stored }: { text: string; whole: Whole<string | null>; stored: boolean }) {
326
+ const loaded = whole.state.status === 'loaded';
327
+
328
+ return <AnsiOutput text={text} limit={loaded ? undefined : OUTPUT_LIMIT} marksOmitted={loaded || !stored} />;
329
+ }
330
+
254
331
  /* A diff keeps its card, with what the call answered under it. */
255
- function DiffBody({ tool, entries, patched }: { tool: ChatToolItem; entries: readonly ReviewEntry[]; patched: boolean }) {
332
+ function DiffBody({ tool, entries, patched, chatId }: { tool: ChatToolItem; entries: readonly ReviewEntry[]; patched: boolean; chatId: string | undefined }) {
333
+ const { t } = useTranslation('agent-chat');
334
+ const { text, whole } = useToolText(tool, chatId);
335
+
256
336
  return (
257
337
  <div className="mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised">
258
338
  <DiffStack entries={entries} />
259
- {hasOutput(tool.output) && !patched && (
260
- <pre
261
- data-find-field="output"
262
- className={clsx(
263
- 'max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text',
264
- tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'
265
- )}
266
- >
267
- <AnsiOutput text={tool.output} limit={OUTPUT_LIMIT} />
268
- </pre>
339
+ {hasOutput(text) && !patched && (
340
+ <div className="border-t border-border px-3 py-2">
341
+ <pre
342
+ data-find-field="output"
343
+ className={clsx(
344
+ 'max-h-64 overflow-auto whitespace-pre-wrap font-mono text-code select-text',
345
+ tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'
346
+ )}
347
+ >
348
+ <OutputText text={text} whole={whole} stored={tool.outputRef !== undefined} />
349
+ </pre>
350
+ <WholeBar whole={whole} label={t('work.showFullOutput')} failedLabel={t('work.fullOutputFailed')} bytes={tool.outputRef?.bytes} />
351
+ </div>
269
352
  )}
270
353
  </div>
271
354
  );
272
355
  }
273
356
 
274
357
  /* What a call answered, muted under the call; a failure in the error color, or its exit code when the tool names one. */
275
- function ToolOutput({ tool }: { tool: ChatToolItem }) {
358
+ function ToolOutput({ tool, chatId }: { tool: ChatToolItem; chatId: string | undefined }) {
276
359
  const { t } = useTranslation('agent-chat');
360
+ const { text, whole } = useToolText(tool, chatId);
277
361
 
278
- if (!hasOutput(tool.output)) {
362
+ if (!hasOutput(text)) {
279
363
  return null;
280
364
  }
281
365
 
282
366
  const failed = tool.state === 'error';
283
- const { code, rest } = failed ? exitCodeOf(tool.output) : { code: null, rest: tool.output };
367
+ const { code, rest } = failed ? exitCodeOf(text) : { code: null, rest: text };
284
368
 
285
369
  return (
286
370
  <>
@@ -289,21 +373,22 @@ function ToolOutput({ tool }: { tool: ChatToolItem }) {
289
373
  data-find-field="output"
290
374
  className={clsx('max-h-64 overflow-auto whitespace-pre-wrap', failed && code === null ? 'text-status-error' : 'text-text-muted')}
291
375
  >
292
- <AnsiOutput text={rest} limit={OUTPUT_LIMIT} />
376
+ <OutputText text={rest} whole={whole} stored={tool.outputRef !== undefined} />
293
377
  </pre>
294
378
  )}
379
+ <WholeBar whole={whole} label={t('work.showFullOutput')} failedLabel={t('work.fullOutputFailed')} bytes={tool.outputRef?.bytes} />
295
380
  {code !== null && <div className="text-status-error">{t('work.exitCode', { code: String(code) })}</div>}
296
381
  </>
297
382
  );
298
383
  }
299
384
 
300
- /* An opened call without a card: what it was asked in the foreground, what it answered muted below. */
301
- export function ToolBody({ tool }: { tool: ChatToolItem }) {
385
+ /* An opened call without a card: what it was asked in the foreground, what it answered muted below. With `chatId` it can ask for an output the thread keeps only the beginning of. */
386
+ export function ToolBody({ tool, chatId }: { tool: ChatToolItem; chatId?: string | undefined }) {
302
387
  const patches = unifiedChanges(tool);
303
388
  const changes = patches.length > 0 ? [] : fileChanges(tool.name, tool.input);
304
389
 
305
390
  if (patches.length + changes.length > 0) {
306
- return <DiffBody tool={tool} entries={reviewEntries(patches, changes)} patched={patches.length > 0} />;
391
+ return <DiffBody tool={tool} entries={reviewEntries(patches, changes)} patched={patches.length > 0} chatId={chatId} />;
307
392
  }
308
393
 
309
394
  const lines = toolCallLines(tool.name, tool.input);
@@ -327,7 +412,7 @@ export function ToolBody({ tool }: { tool: ChatToolItem }) {
327
412
  </dl>
328
413
  )}
329
414
  {lines.text !== null && <pre className="max-h-64 overflow-auto whitespace-pre-wrap wrap-anywhere text-text">{clip(lines.text)}</pre>}
330
- <ToolOutput tool={tool} />
415
+ <ToolOutput tool={tool} chatId={chatId} />
331
416
  </div>
332
417
  );
333
418
  }
@@ -380,7 +465,7 @@ export function WorkRow({
380
465
  time={tool.createdAt}
381
466
  />
382
467
  {image !== null && ReadImage !== null && <ReadImage path={image} />}
383
- {open && opens && <ToolBody tool={tool} />}
468
+ {open && opens && <ToolBody tool={tool} chatId={chatId} />}
384
469
  </div>
385
470
  );
386
471
  }
@@ -427,7 +512,7 @@ export function WorkLiveRow({ chatId, tool: derived }: { chatId: string; tool: C
427
512
  }
428
513
  live
429
514
  />
430
- {open && opens && <ToolBody tool={tool} />}
515
+ {open && opens && <ToolBody tool={tool} chatId={chatId} />}
431
516
  {tail !== null && !open && (
432
517
  <pre className="mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text">
433
518
  <AnsiOutput text={tail} tailLines={12} />
@@ -520,12 +605,22 @@ function omittedLabel(reason: 'binary' | 'too-large'): string {
520
605
  return reason === 'binary' ? i18next.t('agent-chat:work.omitted.binary') : i18next.t('agent-chat:work.omitted.tooLarge');
521
606
  }
522
607
 
523
- /* One file of a turn's checkpoint diff: its patch, or the reason there is none. */
524
- function CheckpointFileBody({ file }: { file: ChatCheckpointFile }) {
608
+ /* One file of a turn's checkpoint diff: its patch, the reason there is none, or the offer of the whole diff where the thread held this one back. */
609
+ function CheckpointFileBody({ file, whole }: { file: ChatCheckpointFile; whole: Whole<ChatCheckpointDiff | null> | null }) {
610
+ const { t } = useTranslation('agent-chat');
611
+
525
612
  if (file.omitted) {
526
613
  return <div className="px-3 py-2 text-xs text-text-faint">{omittedLabel(file.omitted)}</div>;
527
614
  }
528
615
 
616
+ if (whole !== null && file.diff === '') {
617
+ return (
618
+ <div className="px-3">
619
+ <WholeBar whole={whole} label={t('work.showFullDiff')} failedLabel={t('work.fullDiffFailed')} />
620
+ </div>
621
+ );
622
+ }
623
+
529
624
  return (
530
625
  <DiffSuspense>
531
626
  <UnifiedDiff change={file} />
@@ -595,6 +690,7 @@ function ChangedFilesCard({
595
690
  * checkpoint diff comes first (the working tree against the tree the turn started from), then the
596
691
  * unified diffs the CLI reported, then the before and after of its own edits. A turn that has a
597
692
  * checkpoint but no stored diff asks the host for one, which is also how a running turn gets it.
693
+ * A diff the thread keeps only the first files of (`heldBack`) asks for the whole when a person does.
598
694
  */
599
695
  export function ChangedFilesRow({
600
696
  tools,
@@ -602,7 +698,8 @@ export function ChangedFilesRow({
602
698
  checkpoint,
603
699
  baseMoved,
604
700
  chatId,
605
- turnId
701
+ turnId,
702
+ heldBack = false
606
703
  }: {
607
704
  tools: ChatToolItem[];
608
705
  diff: ChatCheckpointDiff | null;
@@ -610,10 +707,13 @@ export function ChangedFilesRow({
610
707
  baseMoved?: BaseMoved | undefined;
611
708
  chatId: string;
612
709
  turnId: string;
710
+ heldBack?: boolean;
613
711
  }) {
614
712
  const [open, setOpen] = useState<Record<string, boolean>>({});
615
713
  const [fetched, setFetched] = useState<ChatCheckpointDiff | null>(null);
616
714
  const actions = useChatActions();
715
+ const whole = useWhole(heldBack ? () => actions.turnDiff(chatId, turnId) : null);
716
+ const loaded = whole.state.status === 'loaded' ? whole.state.value : null;
617
717
 
618
718
  useEffect(() => {
619
719
  if (diff !== null || !checkpoint) {
@@ -636,7 +736,7 @@ export function ChangedFilesRow({
636
736
  };
637
737
  }, [actions, chatId, turnId, diff, checkpoint]);
638
738
 
639
- const checkpointDiff = diff ?? fetched;
739
+ const checkpointDiff = loaded ?? diff ?? fetched;
640
740
  const { keyOf } = useChatScope();
641
741
  const flash = useTimelineFlash((s) => s.target);
642
742
  const revealNonce = flash !== null && flash.opens && flash.key === keyOf(chatId) && flash.rowId === `files-${turnId}` ? flash.nonce : 0;
@@ -666,7 +766,7 @@ export function ChangedFilesRow({
666
766
  </>
667
767
  }
668
768
  >
669
- <CheckpointFileBody file={file} />
769
+ <CheckpointFileBody file={file} whole={loaded === null && heldBack ? whole : null} />
670
770
  </ChangedFileLine>
671
771
  ))}
672
772
  </ChangedFilesCard>
@@ -202,7 +202,7 @@ export function WorkflowRow({
202
202
  </div>
203
203
  {running && chatId && workflow.taskId && <IconButton icon={Square} size="xs" label={t('activity.stop')} onClick={stop} />}
204
204
  </div>
205
- {open && opens && <ToolBody tool={tool} />}
205
+ {open && opens && <ToolBody tool={tool} chatId={chatId} />}
206
206
  <div className="mt-1.5 ml-6">
207
207
  {workflow.name !== null && <div className="truncate text-sm font-medium text-text">{workflow.name}</div>}
208
208
  {description !== null && <p className="mt-0.5 line-clamp-2 text-xs text-text-muted">{description}</p>}
package/src/host.ts CHANGED
@@ -63,6 +63,8 @@ export interface ChatActions {
63
63
  continueOn(chatId: string, account: string): Promise<void>;
64
64
  /* Null when the turn has no checkpoint to compare against. */
65
65
  turnDiff(chatId: string, turnId: string): Promise<ChatCheckpointDiff | null>;
66
+ /* The whole of what a tool call answered, where its item carries only the beginning; without it that beginning is all a person sees. */
67
+ toolOutput?(chatId: string, itemId: string): Promise<string | null>;
66
68
  stopSubagent(chatId: string, toolUseId: string): Promise<void>;
67
69
  stopTask(chatId: string, taskId: string): Promise<void>;
68
70
  /* Pauses, resumes or clears the chat's goal; absent where the host offers none of it, which leaves the goal row without its buttons. */
@@ -217,7 +217,13 @@ export default {
217
217
  alsoRan: '+ {{programs}}',
218
218
  heredoc: 'heredoc',
219
219
  moreRuns_one: '+{{count}}',
220
- moreRuns_other: '+{{count}}'
220
+ moreRuns_other: '+{{count}}',
221
+ showFullOutput: 'Show full output',
222
+ showFullDiff: 'Show full diff',
223
+ wholeSize: '{{size}} in total',
224
+ fullOutputFailed: 'Could not load the full output',
225
+ fullDiffFailed: 'Could not load the full diff',
226
+ retry: 'Try again'
221
227
  },
222
228
  timeline: {
223
229
  welcome: {
@@ -536,7 +542,10 @@ export default {
536
542
  image: {
537
543
  zoomIn: 'Zoom in',
538
544
  zoomOut: 'Zoom out',
539
- reset: 'Reset'
545
+ reset: 'Reset',
546
+ previous: 'Previous image',
547
+ next: 'Next image',
548
+ position: '{{index}} of {{count}}'
540
549
  },
541
550
  generatedImage: {
542
551
  label: 'Generated image',
@@ -217,7 +217,13 @@ export default {
217
217
  alsoRan: '+ {{programs}}',
218
218
  heredoc: 'heredoc',
219
219
  moreRuns_one: '+{{count}}',
220
- moreRuns_other: '+{{count}}'
220
+ moreRuns_other: '+{{count}}',
221
+ showFullOutput: 'Volledige uitvoer tonen',
222
+ showFullDiff: 'Volledige diff tonen',
223
+ wholeSize: '{{size}} in totaal',
224
+ fullOutputFailed: 'De volledige uitvoer kon niet worden geladen',
225
+ fullDiffFailed: 'De volledige diff kon niet worden geladen',
226
+ retry: 'Opnieuw proberen'
221
227
  },
222
228
  timeline: {
223
229
  welcome: {
@@ -536,7 +542,10 @@ export default {
536
542
  image: {
537
543
  zoomIn: 'Inzoomen',
538
544
  zoomOut: 'Uitzoomen',
539
- reset: 'Herstellen'
545
+ reset: 'Herstellen',
546
+ previous: 'Vorige afbeelding',
547
+ next: 'Volgende afbeelding',
548
+ position: '{{index}} van {{count}}'
540
549
  },
541
550
  generatedImage: {
542
551
  label: 'Gegenereerde afbeelding',