@adecore/agents-react 0.40.3 → 0.40.5

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.
@@ -80,13 +80,26 @@ function readAsBase64(file) {
80
80
  function nameFor(file, index) {
81
81
  return file.name || `pasted-${Date.now()}-${index + 1}.${file.type.split('/')[1] ?? 'bin'}`;
82
82
  }
83
+ function fileMime(file) {
84
+ const image = attachmentImageMime({
85
+ name: file.name,
86
+ mime: file.type
87
+ });
88
+ if (image !== null) {
89
+ return image;
90
+ }
91
+ if (file.type === '' || file.type === 'application/octet-stream' || file.type === 'binary/octet-stream') {
92
+ const extension = /\.(heic|heif)$/i.exec(file.name)?.[1]?.toLowerCase();
93
+ if (extension !== undefined) {
94
+ return `image/${extension}`;
95
+ }
96
+ }
97
+ return file.type || 'application/octet-stream';
98
+ }
83
99
  export async function readAttachments(files) {
84
100
  return Promise.all(files.map(async (file, index) => ({
85
101
  name: nameFor(file, index),
86
- mime: attachmentImageMime({
87
- name: file.name,
88
- mime: file.type
89
- }) ?? (file.type || 'application/octet-stream'),
102
+ mime: fileMime(file),
90
103
  data: await readAsBase64(file)
91
104
  })));
92
105
  }
@@ -398,7 +398,8 @@ export class ChatClient {
398
398
  const page = result.history === undefined ? undefined : {
399
399
  history: result.history,
400
400
  pending: result.pending ?? [],
401
- subagents: result.subagents ?? []
401
+ subagents: result.subagents ?? [],
402
+ outline: result.outline ?? []
402
403
  };
403
404
  this.sink.reset(chatId, result.info, result.items, page);
404
405
  }
@@ -0,0 +1,4 @@
1
+ /** Converts HEIC and HEIF to a browser-readable PNG for display, preserving other formats and the source bytes. */
2
+ export declare function imageForDisplay(blob: Blob): Promise<Blob>;
3
+ /** Makes embedded HEIC/HEIF data URLs readable in a sandboxed HTML preview without changing the saved document. */
4
+ export declare function imageDocumentForDisplay(html: string): Promise<string>;
@@ -0,0 +1,39 @@
1
+ /** Converts HEIC and HEIF to a browser-readable PNG for display, preserving other formats and the source bytes. */
2
+ export async function imageForDisplay(blob) {
3
+ if (!/^image\/(?:heic|heif)(?:-sequence)?$/i.test(blob.type)) {
4
+ return blob;
5
+ }
6
+ // The CSP build decodes in a worker without requiring unsafe-eval on the host page.
7
+ const {
8
+ heicTo
9
+ } = await import('heic-to/csp');
10
+ return heicTo({
11
+ blob,
12
+ type: 'image/png'
13
+ });
14
+ }
15
+ /** Makes embedded HEIC/HEIF data URLs readable in a sandboxed HTML preview without changing the saved document. */
16
+ export async function imageDocumentForDisplay(html) {
17
+ const sources = [...new Set(html.match(/data:image\/(?:heic|heif)(?:-sequence)?;base64,[A-Za-z0-9+/=]+/gi) ?? [])];
18
+ let result = html;
19
+ for (const source of sources) {
20
+ try {
21
+ const comma = source.indexOf(',');
22
+ const mime = source.slice(5, source.indexOf(';')).toLowerCase();
23
+ const bytes = Uint8Array.from(atob(source.slice(comma + 1)), character => character.charCodeAt(0));
24
+ const blob = await imageForDisplay(new Blob([bytes], {
25
+ type: mime
26
+ }));
27
+ const converted = await new Promise((resolve, reject) => {
28
+ const reader = new FileReader();
29
+ reader.onload = () => resolve(String(reader.result));
30
+ reader.onerror = () => reject(reader.error);
31
+ reader.readAsDataURL(blob);
32
+ });
33
+ result = result.replaceAll(source, converted);
34
+ } catch {
35
+ // A broken picture must not prevent the rest of the document from opening.
36
+ }
37
+ }
38
+ return result;
39
+ }
@@ -1,5 +1,5 @@
1
- import type { ChatBookmark } from '@adecore/agent-contracts';
2
- import type { TimelineRow } from './timeline.js';
1
+ import type { ChatBookmark, ChatItem } from '@adecore/agent-contracts';
2
+ import { type TimelineRow } from './timeline.js';
3
3
  export type TickKind = 'person' | 'wake' | 'bookmark';
4
4
  export interface ScrubberTick {
5
5
  id: string;
@@ -15,7 +15,8 @@ export declare const TICK_HEIGHT_PX = 2;
15
15
  export declare const STRIP_WIDTH_PX = 24;
16
16
  export declare const STRIP_INSET_PX = 8;
17
17
  export declare const STRIP_CLEARANCE_PX: number;
18
- export declare function ticksOf(rows: readonly TimelineRow[], marks?: ReadonlyMap<string, ChatBookmark>): ScrubberTick[];
18
+ export declare const EARLIER_ROW = -1;
19
+ export declare function ticksOf(rows: readonly TimelineRow[], marks?: ReadonlyMap<string, ChatBookmark>, earlier?: readonly ChatItem[], bookmarks?: readonly ChatBookmark[]): ScrubberTick[];
19
20
  export interface TickSlot {
20
21
  first: number;
21
22
  last: number;
@@ -1,3 +1,4 @@
1
+ import { agentTurnLabel } from './timeline.js';
1
2
  import { DEFAULT_READING_WIDTH, readingMeasure } from '../reading-width.js';
2
3
  /* With fewer messages than this the thread is short enough to scroll through, and the strip is noise. */
3
4
  export const SCRUBBER_MIN_TICKS = 3;
@@ -15,9 +16,56 @@ const WIDTH_NEAR_PERSON_PX = 12;
15
16
  const WIDTH_NEAR_WAKE_PX = 8;
16
17
  const WIDTH_PERSON_PX = 8;
17
18
  const WIDTH_WAKE_PX = 5;
18
- /* `marks` is what `bookmarkRows` answers: the bookmark drawn at a row, by row id. */
19
- export function ticksOf(rows, marks = new Map()) {
20
- return rows.flatMap((row, rowIndex) => {
19
+ /* Below every row, so a tick before the rows this client holds sorts first and its stretch reaches the first row of the thread. */
20
+ export const EARLIER_ROW = -1;
21
+ /* A message before the rows this client holds, as the outline (`outlineItems`) has it; its row id is the one the thread will draw it with. */
22
+ function earlierTick(item, bookmarks) {
23
+ const bookmark = bookmarks.get(item.id) ?? null;
24
+ const rowIndex = EARLIER_ROW;
25
+ if (item.kind === 'user') {
26
+ const text = item.text !== '' ? item.text : (item.attachments ?? []).map(attachment => attachment.name).join(', ');
27
+ return {
28
+ id: item.id,
29
+ rowIndex,
30
+ kind: 'person',
31
+ text,
32
+ createdAt: item.createdAt,
33
+ turnId: item.turnId,
34
+ bookmark
35
+ };
36
+ }
37
+ if (item.kind === 'turn' && ((item.taskIds?.length ?? 0) > 0 || (item.messageFrom?.length ?? 0) > 0)) {
38
+ return {
39
+ id: `start-${item.id}`,
40
+ rowIndex,
41
+ kind: 'wake',
42
+ text: agentTurnLabel(item),
43
+ createdAt: item.createdAt,
44
+ turnId: item.id,
45
+ bookmark: null
46
+ };
47
+ }
48
+ if (item.kind === 'assistant' && bookmark !== null) {
49
+ return {
50
+ id: item.id,
51
+ rowIndex,
52
+ kind: 'bookmark',
53
+ text: item.text,
54
+ createdAt: item.createdAt,
55
+ turnId: item.turnId,
56
+ bookmark
57
+ };
58
+ }
59
+ return null;
60
+ }
61
+ /*
62
+ * `marks` is what `bookmarkRows` answers: the bookmark drawn at a row, by row id. `earlier` is the
63
+ * outline of the thread before `rows`, and `bookmarks` the chat's, which mark the ticks of that part.
64
+ */
65
+ export function ticksOf(rows, marks = new Map(), earlier = [], bookmarks = []) {
66
+ const byItem = new Map(bookmarks.map(bookmark => [bookmark.itemId, bookmark]));
67
+ const before = earlier.flatMap(item => earlierTick(item, byItem) ?? []);
68
+ const held = rows.flatMap((row, rowIndex) => {
21
69
  const bookmark = marks.get(row.id) ?? null;
22
70
  if (row.kind === 'user') {
23
71
  // A message of only attachments still has a place in the thread; its names stand in for the text.
@@ -70,6 +118,7 @@ export function ticksOf(rows, marks = new Map()) {
70
118
  bookmark
71
119
  }];
72
120
  });
121
+ return before.length === 0 ? held : [...before, ...held];
73
122
  }
74
123
  /* `marked` and `found` say per message whether it has a bookmark and whether a find hit falls under it; absent, none has. */
75
124
  export function layoutTicks(kinds, height, marked = [], found = []) {
@@ -2,6 +2,7 @@ import { c as _c } from "react/compiler-runtime";
2
2
  import i18next from 'i18next';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { uploadPreviewUrl } from './attachments.js';
5
+ import { imageForDisplay } from './image-display.js';
5
6
  /* The edge of a composer thumbnail: a few times the 56 px it is drawn at, so a sharp screen still gets a sharp one. */
6
7
  export const THUMBNAIL_PX = 256;
7
8
  /* The square in the middle of an image that a thumbnail shows, the same part `object-cover` would. */
@@ -36,16 +37,18 @@ function dataUrlOf(blob) {
36
37
  * cannot be decoded this way (an SVG, a browser without `OffscreenCanvas`) falls back to the image.
37
38
  */
38
39
  async function makeThumbnail(upload) {
40
+ let image = null;
39
41
  try {
40
- const bitmap = await createImageBitmap(new Blob([bytesOf(upload.data)], {
42
+ image = await imageForDisplay(new Blob([bytesOf(upload.data)], {
41
43
  type: upload.mime
42
44
  }));
45
+ const bitmap = await createImageBitmap(image);
43
46
  const crop = thumbnailCrop(bitmap.width, bitmap.height);
44
47
  const canvas = new OffscreenCanvas(crop.out, crop.out);
45
48
  const context = canvas.getContext('2d');
46
49
  if (!context) {
47
50
  bitmap.close();
48
- return uploadPreviewUrl(upload);
51
+ return await dataUrlOf(image);
49
52
  }
50
53
  context.drawImage(bitmap, crop.sx, crop.sy, crop.size, crop.size, 0, 0, crop.out, crop.out);
51
54
  bitmap.close();
@@ -53,7 +56,7 @@ async function makeThumbnail(upload) {
53
56
  type: 'image/png'
54
57
  }));
55
58
  } catch {
56
- return uploadPreviewUrl(upload);
59
+ return image === null ? uploadPreviewUrl(upload) : dataUrlOf(image);
57
60
  }
58
61
  }
59
62
  // Keyed by the upload itself, so a composer that mounts again finds it and a removed file lets it go.
@@ -0,0 +1,5 @@
1
+ import { type ComponentProps } from 'react';
2
+ type ImageProps = ComponentProps<'img'>;
3
+ /** An image with a HEIC/HEIF fallback for browsers without a native decoder. Remote sources must allow cross-origin reads. */
4
+ export declare function DisplayImage({ src, onError, ...props }: ImageProps): import("react").JSX.Element;
5
+ export {};
@@ -0,0 +1,109 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { imageDocumentForDisplay, imageForDisplay } from '../image-display.js';
4
+ /** An image with a HEIC/HEIF fallback for browsers without a native decoder. Remote sources must allow cross-origin reads. */
5
+ import { jsx as _jsx } from "react/jsx-runtime";
6
+ export function DisplayImage(t0) {
7
+ const $ = _c(9);
8
+ let onError;
9
+ let props;
10
+ let src;
11
+ if ($[0] !== t0) {
12
+ ({
13
+ src,
14
+ onError,
15
+ ...props
16
+ } = t0);
17
+ $[0] = t0;
18
+ $[1] = onError;
19
+ $[2] = props;
20
+ $[3] = src;
21
+ } else {
22
+ onError = $[1];
23
+ props = $[2];
24
+ src = $[3];
25
+ }
26
+ const t1 = typeof src === "string" ? src : undefined;
27
+ let t2;
28
+ if ($[4] !== onError || $[5] !== props || $[6] !== src || $[7] !== t1) {
29
+ t2 = /*#__PURE__*/_jsx(ImageSource, {
30
+ ...props,
31
+ src: src,
32
+ onError: onError
33
+ }, t1);
34
+ $[4] = onError;
35
+ $[5] = props;
36
+ $[6] = src;
37
+ $[7] = t1;
38
+ $[8] = t2;
39
+ } else {
40
+ t2 = $[8];
41
+ }
42
+ return t2;
43
+ }
44
+ function ImageSource({
45
+ src,
46
+ onError,
47
+ ...props
48
+ }) {
49
+ const [retry, setRetry] = useState(null);
50
+ const [decoded, setDecoded] = useState(null);
51
+ const converted = decoded !== null && decoded.source === src ? decoded.url : null;
52
+ useEffect(() => {
53
+ if (typeof src !== 'string' || retry !== src) {
54
+ return;
55
+ }
56
+ let cancelled = false;
57
+ let url = null;
58
+ const convert = async () => {
59
+ try {
60
+ const embedded = await imageDocumentForDisplay(src);
61
+ if (cancelled) {
62
+ return;
63
+ }
64
+ if (embedded !== src) {
65
+ setDecoded({
66
+ source: src,
67
+ url: embedded
68
+ });
69
+ return;
70
+ }
71
+ const response = await fetch(src, {
72
+ referrerPolicy: 'no-referrer'
73
+ });
74
+ if (!response.ok) {
75
+ return;
76
+ }
77
+ const blob = await response.blob();
78
+ const image = await imageForDisplay(blob);
79
+ if (cancelled || image === blob) {
80
+ return;
81
+ }
82
+ url = URL.createObjectURL(image);
83
+ setDecoded({
84
+ source: src,
85
+ url
86
+ });
87
+ } catch {
88
+ // The failed image keeps the browser's alt text when it cannot be read or decoded.
89
+ }
90
+ };
91
+ void convert();
92
+ return () => {
93
+ cancelled = true;
94
+ if (url !== null) {
95
+ URL.revokeObjectURL(url);
96
+ }
97
+ };
98
+ }, [src, retry]);
99
+ return /*#__PURE__*/_jsx("img", {
100
+ src: converted ?? src,
101
+ ...props,
102
+ onError: event => {
103
+ if (typeof src === 'string' && retry !== src) {
104
+ setRetry(src);
105
+ }
106
+ onError?.(event);
107
+ }
108
+ });
109
+ }
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import clsx from 'clsx';
5
5
  import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
6
6
  import { ButtonGroup, CloseButton, Dialog, Icon, IconButton, Tooltip } from '@adecore/ui';
7
+ import { DisplayImage } from './DisplayImage.js';
7
8
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
8
9
  const MIN_SCALE = 1;
9
10
  const MAX_SCALE = 8;
@@ -404,7 +405,7 @@ function Lightbox(t0) {
404
405
  }
405
406
  let t39;
406
407
  if ($[67] !== alt || $[68] !== src || $[69] !== t38) {
407
- t39 = /*#__PURE__*/_jsx("img", {
408
+ t39 = /*#__PURE__*/_jsx(DisplayImage, {
408
409
  src: src,
409
410
  alt: alt,
410
411
  draggable: false,
@@ -581,7 +582,7 @@ export function ImageThumb(t0) {
581
582
  }
582
583
  let t8;
583
584
  if ($[19] !== alt || $[20] !== src || $[21] !== t7) {
584
- t8 = /*#__PURE__*/_jsx("img", {
585
+ t8 = /*#__PURE__*/_jsx(DisplayImage, {
585
586
  src: src,
586
587
  alt: alt,
587
588
  className: t7
@@ -19,6 +19,7 @@ import { useChatHost } from '../../host.js';
19
19
  import { useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget, useOpenFileLink } from './file-links.js';
20
20
  import { ErrorBoundary, FileIcon, Icon } from '@adecore/ui';
21
21
  import { useTranslation } from 'react-i18next';
22
+ import { DisplayImage } from './DisplayImage.js';
22
23
  import { jsx as _jsx } from "react/jsx-runtime";
23
24
  const ShellCodeBlockReplyContext = createContext(null);
24
25
  const BlockCodeContext = createContext(false);
@@ -330,7 +331,7 @@ function MarkdownImage(t0) {
330
331
  if (HostImage === undefined || path === null) {
331
332
  let t2;
332
333
  if ($[3] !== alt || $[4] !== src) {
333
- t2 = /*#__PURE__*/_jsx("img", {
334
+ t2 = /*#__PURE__*/_jsx(DisplayImage, {
334
335
  src: src,
335
336
  alt: alt
336
337
  });
@@ -366,7 +367,7 @@ function PlainImage(t0) {
366
367
  } = t0;
367
368
  let t1;
368
369
  if ($[0] !== alt || $[1] !== src) {
369
- t1 = /*#__PURE__*/_jsx("img", {
370
+ t1 = /*#__PURE__*/_jsx(DisplayImage, {
370
371
  src: src,
371
372
  alt: alt
372
373
  });
@@ -13,7 +13,7 @@ import { registerItemJumper, registerMessageStepper, registerTimeline, registerT
13
13
  import { useTimelineFlash } from '../timeline-flash.js';
14
14
  import { useChatPreferences } from '../preferences.js';
15
15
  import { readingMeasure, readingWidthStyle } from '../reading-width.js';
16
- import { SCRUBBER_MIN_TICKS, STRIP_INSET_PX, STRIP_WIDTH_PX, messagesInView, stepMessage, threadPaddingLeft, STRIP_CLEARANCE_PX, tickOfRow, ticksOf, ticksWithHits } from '../logic/scrubber.js';
16
+ import { SCRUBBER_MIN_TICKS, STRIP_INSET_PX, STRIP_WIDTH_PX, messagesInView, stepMessage, threadPaddingLeft, STRIP_CLEARANCE_PX, tickOfRow, ticksOf, ticksWithHits, EARLIER_ROW } from '../logic/scrubber.js';
17
17
  import { EMPTY_TARGET, readTimelineTarget, revealUiReplyBlock, uiReplyJump, withCurrentText } from '../logic/timeline-target.js';
18
18
  import { forkRefusal } from '../logic/fork.js';
19
19
  import { UiReplyNavigationContext } from './reply-context.js';
@@ -410,11 +410,12 @@ export function Timeline({
410
410
  const items = useChatRow(chatId, row_0 => row_0?.structure);
411
411
  const activeTurnId = useChatRow(chatId, row_1 => row_1?.info.activeTurnId ?? null);
412
412
  const bookmarks = useChatRow(chatId, row_2 => row_2?.bookmarks);
413
- const visuals = useChatRow(chatId, row_3 => row_3?.visuals);
413
+ const outlineBefore = useChatRow(chatId, row_3 => row_3?.outlineBefore);
414
+ const visuals = useChatRow(chatId, row_4 => row_4?.visuals);
414
415
  const forkedTurns = useForkedTurns(chatId);
415
- const info = useChatRow(chatId, row_4 => row_4?.info ?? null);
416
- const historyPending = useChatRow(chatId, row_5 => row_5?.historyPending === true);
417
- const cursor = useChatRow(chatId, row_6 => row_6?.history?.cursor ?? null);
416
+ const info = useChatRow(chatId, row_5 => row_5?.info ?? null);
417
+ const historyPending = useChatRow(chatId, row_6 => row_6?.historyPending === true);
418
+ const cursor = useChatRow(chatId, row_7 => row_7?.history?.cursor ?? null);
418
419
  const scope = useChatScope();
419
420
  const {
420
421
  id: scopeId,
@@ -499,7 +500,7 @@ export function Timeline({
499
500
  // A chat that took a turn before is one whose thread has not come in yet, not one to greet.
500
501
  const welcome = !loadingHistory && welcomes && empty && (info === null || info.usage.turns === 0 && info.activeTurnId === null);
501
502
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
502
- const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
503
+ const ticks = useMemo(() => ticksOf(rows, marks, outlineBefore, bookmarks), [rows, marks, outlineBefore, bookmarks]);
503
504
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
504
505
  const [seeking, setSeeking] = useState(null);
505
506
  const [landing, setLanding] = useState(null);
@@ -601,7 +602,7 @@ export function Timeline({
601
602
  }
602
603
  // Ref measurements can change offsets after render. Commit those offsets before the first paint.
603
604
  const measured = virtualizer.getVirtualItems();
604
- if (virtualRows.length !== measured.length || virtualRows.some((row_7, index_1) => row_7.key !== measured[index_1]?.key || row_7.start !== measured[index_1]?.start || row_7.size !== measured[index_1]?.size)) {
605
+ if (virtualRows.length !== measured.length || virtualRows.some((row_8, index_1) => row_8.key !== measured[index_1]?.key || row_8.start !== measured[index_1]?.start || row_8.size !== measured[index_1]?.size)) {
605
606
  settleLayout();
606
607
  return;
607
608
  }
@@ -640,16 +641,16 @@ export function Timeline({
640
641
  return;
641
642
  }
642
643
  const anchor = anchorRef.current;
643
- const index_2 = moved && anchor !== null && !followRef.current ? rows.findIndex(row_8 => row_8.id === anchor.id) : -1;
644
+ const index_2 = moved && anchor !== null && !followRef.current ? rows.findIndex(row_9 => row_9.id === anchor.id) : -1;
644
645
  if (anchor !== null && index_2 !== -1) {
645
646
  virtualizer.getTotalSize();
646
647
  element_3.scrollTop = virtualizer.measurementsCache[index_2].start - anchor.offset;
647
648
  return;
648
649
  }
649
- const row_9 = virtualizer.getVirtualItemForOffset(element_3.scrollTop);
650
- anchorRef.current = row_9 === undefined ? null : {
651
- id: String(row_9.key),
652
- offset: row_9.start - element_3.scrollTop
650
+ const row_10 = virtualizer.getVirtualItemForOffset(element_3.scrollTop);
651
+ anchorRef.current = row_10 === undefined ? null : {
652
+ id: String(row_10.key),
653
+ offset: row_10.start - element_3.scrollTop
653
654
  };
654
655
  });
655
656
  // Close to the top, the page before goes in; again after each page that left too little above the reader.
@@ -759,10 +760,11 @@ export function Timeline({
759
760
  if (!tick_0) {
760
761
  return;
761
762
  }
762
- if (tick_0.bookmark !== null) {
763
+ // A message before the rows this client holds comes in page by page until its row is there.
764
+ if (tick_0.bookmark !== null || tick_0.rowIndex === EARLIER_ROW) {
763
765
  setLanding(null);
764
766
  setSeeking({
765
- itemId: tick_0.bookmark.itemId,
767
+ itemId: tick_0.bookmark?.itemId ?? tick_0.id,
766
768
  key: keyOf(chatId)
767
769
  });
768
770
  return;
@@ -798,7 +800,7 @@ export function Timeline({
798
800
  }
799
801
  return;
800
802
  }
801
- const index_6 = rows.findIndex(row_10 => row_10.id === seeking.itemId);
803
+ const index_6 = rows.findIndex(row_11 => row_11.id === seeking.itemId);
802
804
  if (index_6 !== -1) {
803
805
  setSeeking(null);
804
806
  showRowAtTop(virtualizer, followRef, index_6);
@@ -872,9 +874,9 @@ export function Timeline({
872
874
  turnId: turnId_0,
873
875
  target: target_0
874
876
  } = revealing;
875
- const changes = target_0 === 'changes' ? rows.findIndex(row_11 => row_11.kind === 'changed-files' && row_11.turnId === turnId_0) : -1;
877
+ const changes = target_0 === 'changes' ? rows.findIndex(row_12 => row_12.kind === 'changed-files' && row_12.turnId === turnId_0) : -1;
876
878
  const opening = order?.map(id_0 => items?.[id_0]).find(item_0 => item_0?.kind === 'user' && item_0.turnId === turnId_0);
877
- const found_0 = changes !== -1 ? changes : rows.findIndex(row_12 => row_12.id === opening?.id || row_12.id === `start-${turnId_0}` || row_12.id === `fold-${turnId_0}`);
879
+ const found_0 = changes !== -1 ? changes : rows.findIndex(row_13 => row_13.id === opening?.id || row_13.id === `start-${turnId_0}` || row_13.id === `fold-${turnId_0}`);
878
880
  if (found_0 !== -1) {
879
881
  setRevealing(null);
880
882
  showRowAtTop(virtualizer, followRef, found_0);
@@ -903,8 +905,8 @@ export function Timeline({
903
905
  if (fork === null) {
904
906
  return false;
905
907
  }
906
- const row_13 = useChats.getState().byKey[keyOf(chatId)];
907
- return forkRefusal(row_13?.info ?? null, row_13?.structure[turnId_2]) === null;
908
+ const row_14 = useChats.getState().byKey[keyOf(chatId)];
909
+ return forkRefusal(row_14?.info ?? null, row_14?.structure[turnId_2]) === null;
908
910
  },
909
911
  fork: turnId_3 => fork?.(chatId, turnId_3)
910
912
  }), [keyOf, chatId, fork]);
@@ -1028,22 +1030,22 @@ export function Timeline({
1028
1030
  children: /*#__PURE__*/_jsx(FindRevealContext.Provider, {
1029
1031
  value: chatFind.reveal,
1030
1032
  children: virtualRows.map(virtualRow_1 => {
1031
- const row_14 = rows[virtualRow_1.index];
1033
+ const row_15 = rows[virtualRow_1.index];
1032
1034
  return /*#__PURE__*/_jsx(ThreadRow, {
1033
- row: row_14,
1035
+ row: row_15,
1034
1036
  previous: virtualRow_1.index > 0 ? rows[virtualRow_1.index - 1] : null,
1035
1037
  index: virtualRow_1.index,
1036
1038
  top: virtualRow_1.start,
1037
1039
  measure: virtualizer.measureElement,
1038
1040
  chatId: chatId,
1039
- flashNonce: flash !== null && flashedRow === row_14.id ? flash.nonce : null,
1040
- bookmark: marks.get(row_14.id) ?? null,
1041
+ flashNonce: flash !== null && flashedRow === row_15.id ? flash.nonce : null,
1042
+ bookmark: marks.get(row_15.id) ?? null,
1041
1043
  toggleGroup: groups.toggle,
1042
1044
  toggleTurn: turns.toggle,
1043
1045
  toggleSubagent: subagents.toggle,
1044
1046
  openSubagent: openSubagent,
1045
1047
  openConversation: openConversation
1046
- }, row_14.id);
1048
+ }, row_15.id);
1047
1049
  })
1048
1050
  })
1049
1051
  })
@@ -7,6 +7,7 @@ import { Spinner, useMeasuredWidth } from '@adecore/ui';
7
7
  import { useChatHost } from '../../host.js';
8
8
  import { useChatScope } from '../../scope.js';
9
9
  import { DEFAULT_HEIGHT_PX, initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height.js';
10
+ import { imageDocumentForDisplay } from '../image-display.js';
10
11
  import { VisualBridge } from '../visual-bridge.js';
11
12
  import { VisualViewportController } from '../visual-viewport.js';
12
13
  import { useVisualTheme } from './visual-theme.js';
@@ -223,7 +224,7 @@ function PageFrame(t0) {
223
224
  visual: {
224
225
  maxHeight
225
226
  },
226
- page: page(),
227
+ page: page().then(imageDocumentForDisplay),
227
228
  theme: opening.theme,
228
229
  mayOpenLink: () => personActsIn(frame),
229
230
  openLink: url => visuals?.openLink(url),
@@ -2,6 +2,7 @@ export { AccountPill } from './AccountPill.js';
2
2
  export { AnsiOutput } from './AnsiOutput.js';
3
3
  export { BookmarkSubmenu } from './BookmarkSubmenu.js';
4
4
  export { ChatActivity, StatusIcon } from './ChatActivity.js';
5
+ export { DisplayImage } from './DisplayImage.js';
5
6
  export { CodeBlock } from './CodeBlock.js';
6
7
  export { Composer } from './Composer.js';
7
8
  export { type ComposerInputHandle, ComposerInput, type InputSelection } from './ComposerInput.js';
@@ -3,6 +3,7 @@ export { AccountPill } from './AccountPill.js';
3
3
  export { AnsiOutput } from './AnsiOutput.js';
4
4
  export { BookmarkSubmenu } from './BookmarkSubmenu.js';
5
5
  export { ChatActivity, StatusIcon } from './ChatActivity.js';
6
+ export { DisplayImage } from './DisplayImage.js';
6
7
  export { CodeBlock } from './CodeBlock.js';
7
8
  export { Composer } from './Composer.js';
8
9
  export { ComposerInput } from './ComposerInput.js';
@@ -12,6 +12,7 @@ import { ImageThumb } from '../../ImageView.js';
12
12
  import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text.js';
13
13
  import { useTruncated } from '../use-truncated.js';
14
14
  import { useHostAttachmentUrl } from '../../../../host-hooks.js';
15
+ import { DisplayImage } from '../../DisplayImage.js';
15
16
  // The number of every source by its node id, which the list counts in its own order.
16
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
17
18
  const SourceNumbers = createContext(new Map());
@@ -445,7 +446,7 @@ export function CompareRenderer(t0) {
445
446
  }
446
447
  let t8;
447
448
  if ($[15] !== first.url || $[16] !== t6 || $[17] !== t7) {
448
- t8 = /*#__PURE__*/_jsx("img", {
449
+ t8 = /*#__PURE__*/_jsx(DisplayImage, {
449
450
  src: t5,
450
451
  alt: t6,
451
452
  draggable: false,
@@ -471,7 +472,7 @@ export function CompareRenderer(t0) {
471
472
  }
472
473
  let t11;
473
474
  if ($[22] !== second.url || $[23] !== t10) {
474
- t11 = /*#__PURE__*/_jsx("img", {
475
+ t11 = /*#__PURE__*/_jsx(DisplayImage, {
475
476
  src: t9,
476
477
  alt: t10,
477
478
  draggable: false,
@@ -0,0 +1,2 @@
1
+ export { imageDocumentForDisplay, imageForDisplay } from './chat/image-display.js';
2
+ export { DisplayImage } from './chat/ui/DisplayImage.js';
package/dist/images.js ADDED
@@ -0,0 +1,2 @@
1
+ export { imageDocumentForDisplay, imageForDisplay } from './chat/image-display.js';
2
+ export { DisplayImage } from './chat/ui/DisplayImage.js';
@@ -8,6 +8,7 @@ export interface ChatState {
8
8
  history?: ChatHistoryPage;
9
9
  waitingBefore?: string[];
10
10
  subagentsBefore?: string[];
11
+ outlineBefore?: ChatItem[];
11
12
  bookmarks?: ChatBookmark[];
12
13
  visuals?: ChatVisual[];
13
14
  }
@@ -16,6 +17,7 @@ export interface ChatPage {
16
17
  history: ChatHistoryPage;
17
18
  pending: ChatItem[];
18
19
  subagents?: ChatSubagentItem[];
20
+ outline?: ChatItem[];
19
21
  }
20
22
  export type ChatsById = Record<string, ChatState>;
21
23
  export type ChatStatuses = Record<string, Pick<ChatState, 'info'>>;
@@ -29,7 +29,8 @@ function stateOf(info, items, page) {
29
29
  order,
30
30
  history: page.history,
31
31
  waitingBefore: before.map(item => item.id),
32
- subagentsBefore: subagents.map(item => item.id)
32
+ subagentsBefore: subagents.map(item => item.id),
33
+ outlineBefore: (page.outline ?? []).filter(item => !(item.id in byId))
33
34
  };
34
35
  }
35
36
  /* An older host names no place in the thread, and it always hands over the whole of it. */
@@ -83,6 +84,7 @@ export function prependPage(state, cursor, page) {
83
84
  history: _history,
84
85
  waitingBefore: _waitingBefore,
85
86
  subagentsBefore: _subagentsBefore,
87
+ outlineBefore: _outlineBefore,
86
88
  ...rest
87
89
  } = loaded;
88
90
  return {
@@ -94,6 +96,7 @@ export function prependPage(state, cursor, page) {
94
96
  }
95
97
  const waitingBefore = (state.waitingBefore ?? []).filter(id => !olderIds.has(id));
96
98
  const subagentsBefore = (state.subagentsBefore ?? []).filter(id => !olderIds.has(id));
99
+ const outlineBefore = (state.outlineBefore ?? []).filter(item => !olderIds.has(item.id));
97
100
  return {
98
101
  ...loaded,
99
102
  items,
@@ -101,7 +104,8 @@ export function prependPage(state, cursor, page) {
101
104
  order,
102
105
  history: page.history,
103
106
  waitingBefore,
104
- subagentsBefore
107
+ subagentsBefore,
108
+ outlineBefore
105
109
  };
106
110
  }
107
111
  /* The requests that still wait, oldest first: those from before the page this client holds, then the thread's own. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.40.3",
3
+ "version": "0.40.5",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,6 +58,11 @@
58
58
  "source": "./src/theme.css",
59
59
  "style": "./src/theme.css",
60
60
  "default": "./dist/theme.css"
61
+ },
62
+ "./images": {
63
+ "source": "./src/images.ts",
64
+ "types": "./dist/images.d.ts",
65
+ "default": "./dist/images.js"
61
66
  }
62
67
  },
63
68
  "scripts": {
@@ -67,11 +72,11 @@
67
72
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
73
  },
69
74
  "dependencies": {
70
- "@adecore/agent-contracts": "0.40.3",
71
- "@adecore/editor": "0.40.3",
72
- "@adecore/editor-react": "0.40.3",
73
- "@adecore/intelligent-ui": "0.40.3",
74
- "@adecore/merge": "0.40.3",
75
+ "@adecore/agent-contracts": "0.40.5",
76
+ "@adecore/editor": "0.40.5",
77
+ "@adecore/editor-react": "0.40.5",
78
+ "@adecore/intelligent-ui": "0.40.5",
79
+ "@adecore/merge": "0.40.5",
75
80
  "@codemirror/commands": "^6.11.1",
76
81
  "@codemirror/language": "^6.13.1",
77
82
  "@codemirror/state": "^6.7.6",
@@ -82,6 +87,7 @@
82
87
  "@pierre/diffs": "^1.6.1",
83
88
  "@tanstack/react-virtual": "^3.14.23",
84
89
  "clsx": "^2.1.1",
90
+ "heic-to": "1.6.6",
85
91
  "lucide-react": "^1.61.0",
86
92
  "maplibre-gl": "^6.16.0",
87
93
  "pele": "1.8.0",
@@ -94,14 +100,14 @@
94
100
  "zustand": "^5.0.16"
95
101
  },
96
102
  "peerDependencies": {
97
- "@adecore/ui": "^0.40.3",
103
+ "@adecore/ui": "^0.40.5",
98
104
  "i18next": "^26.4.2",
99
105
  "react": "^19.3.0",
100
106
  "react-dom": "^19.3.0",
101
107
  "react-i18next": "^17.0.15"
102
108
  },
103
109
  "devDependencies": {
104
- "@adecore/ui": "0.40.3",
110
+ "@adecore/ui": "0.40.5",
105
111
  "@types/bun": "^1.4.4",
106
112
  "@types/react": "^19.3.0",
107
113
  "@types/react-dom": "^19.3.0",
@@ -95,11 +95,29 @@ function nameFor(file: File, index: number): string {
95
95
  return file.name || `pasted-${Date.now()}-${index + 1}.${file.type.split('/')[1] ?? 'bin'}`;
96
96
  }
97
97
 
98
+ function fileMime(file: File): string {
99
+ const image = attachmentImageMime({ name: file.name, mime: file.type });
100
+
101
+ if (image !== null) {
102
+ return image;
103
+ }
104
+
105
+ if (file.type === '' || file.type === 'application/octet-stream' || file.type === 'binary/octet-stream') {
106
+ const extension = /\.(heic|heif)$/i.exec(file.name)?.[1]?.toLowerCase();
107
+
108
+ if (extension !== undefined) {
109
+ return `image/${extension}`;
110
+ }
111
+ }
112
+
113
+ return file.type || 'application/octet-stream';
114
+ }
115
+
98
116
  export async function readAttachments(files: File[]): Promise<ChatAttachmentUpload[]> {
99
117
  return Promise.all(
100
118
  files.map(async (file, index) => ({
101
119
  name: nameFor(file, index),
102
- mime: attachmentImageMime({ name: file.name, mime: file.type }) ?? (file.type || 'application/octet-stream'),
120
+ mime: fileMime(file),
103
121
  data: await readAsBase64(file)
104
122
  }))
105
123
  );
@@ -443,7 +443,9 @@ export class ChatClient {
443
443
  }
444
444
  } else {
445
445
  const page =
446
- result.history === undefined ? undefined : { history: result.history, pending: result.pending ?? [], subagents: result.subagents ?? [] };
446
+ result.history === undefined
447
+ ? undefined
448
+ : { history: result.history, pending: result.pending ?? [], subagents: result.subagents ?? [], outline: result.outline ?? [] };
447
449
 
448
450
  this.sink.reset(chatId, result.info, result.items, page);
449
451
  }
@@ -0,0 +1,38 @@
1
+ /** Converts HEIC and HEIF to a browser-readable PNG for display, preserving other formats and the source bytes. */
2
+ export async function imageForDisplay(blob: Blob): Promise<Blob> {
3
+ if (!/^image\/(?:heic|heif)(?:-sequence)?$/i.test(blob.type)) {
4
+ return blob;
5
+ }
6
+
7
+ // The CSP build decodes in a worker without requiring unsafe-eval on the host page.
8
+ const { heicTo } = await import('heic-to/csp');
9
+
10
+ return heicTo({ blob, type: 'image/png' });
11
+ }
12
+
13
+ /** Makes embedded HEIC/HEIF data URLs readable in a sandboxed HTML preview without changing the saved document. */
14
+ export async function imageDocumentForDisplay(html: string): Promise<string> {
15
+ const sources = [...new Set(html.match(/data:image\/(?:heic|heif)(?:-sequence)?;base64,[A-Za-z0-9+/=]+/gi) ?? [])];
16
+ let result = html;
17
+
18
+ for (const source of sources) {
19
+ try {
20
+ const comma = source.indexOf(',');
21
+ const mime = source.slice(5, source.indexOf(';')).toLowerCase();
22
+ const bytes = Uint8Array.from(atob(source.slice(comma + 1)), (character) => character.charCodeAt(0));
23
+ const blob = await imageForDisplay(new Blob([bytes], { type: mime }));
24
+ const converted = await new Promise<string>((resolve, reject) => {
25
+ const reader = new FileReader();
26
+ reader.onload = () => resolve(String(reader.result));
27
+ reader.onerror = () => reject(reader.error);
28
+ reader.readAsDataURL(blob);
29
+ });
30
+
31
+ result = result.replaceAll(source, converted);
32
+ } catch {
33
+ // A broken picture must not prevent the rest of the document from opening.
34
+ }
35
+ }
36
+
37
+ return result;
38
+ }
@@ -1,5 +1,5 @@
1
- import type { ChatBookmark } from '@adecore/agent-contracts';
2
- import type { TimelineRow } from './timeline';
1
+ import type { ChatBookmark, ChatItem } from '@adecore/agent-contracts';
2
+ import { agentTurnLabel, type TimelineRow } from './timeline';
3
3
  import { DEFAULT_READING_WIDTH, readingMeasure } from '../reading-width';
4
4
 
5
5
  // The strip of ticks at the left edge of a chat view, as numbers; the component only draws what comes out of here.
@@ -9,7 +9,7 @@ export type TickKind = 'person' | 'wake' | 'bookmark';
9
9
 
10
10
  export interface ScrubberTick {
11
11
  id: string;
12
- /* The row of the timeline the tick jumps to. */
12
+ /* The row of the timeline the tick jumps to; `EARLIER_ROW` for a message before the part of the thread this client holds. */
13
13
  rowIndex: number;
14
14
  kind: TickKind;
15
15
  text: string;
@@ -41,9 +41,45 @@ const WIDTH_NEAR_WAKE_PX = 8;
41
41
  const WIDTH_PERSON_PX = 8;
42
42
  const WIDTH_WAKE_PX = 5;
43
43
 
44
- /* `marks` is what `bookmarkRows` answers: the bookmark drawn at a row, by row id. */
45
- export function ticksOf(rows: readonly TimelineRow[], marks: ReadonlyMap<string, ChatBookmark> = new Map()): ScrubberTick[] {
46
- return rows.flatMap((row, rowIndex): ScrubberTick[] => {
44
+ /* Below every row, so a tick before the rows this client holds sorts first and its stretch reaches the first row of the thread. */
45
+ export const EARLIER_ROW = -1;
46
+
47
+ /* A message before the rows this client holds, as the outline (`outlineItems`) has it; its row id is the one the thread will draw it with. */
48
+ function earlierTick(item: ChatItem, bookmarks: ReadonlyMap<string, ChatBookmark>): ScrubberTick | null {
49
+ const bookmark = bookmarks.get(item.id) ?? null;
50
+ const rowIndex = EARLIER_ROW;
51
+
52
+ if (item.kind === 'user') {
53
+ const text = item.text !== '' ? item.text : (item.attachments ?? []).map((attachment) => attachment.name).join(', ');
54
+
55
+ return { id: item.id, rowIndex, kind: 'person', text, createdAt: item.createdAt, turnId: item.turnId, bookmark };
56
+ }
57
+
58
+ if (item.kind === 'turn' && ((item.taskIds?.length ?? 0) > 0 || (item.messageFrom?.length ?? 0) > 0)) {
59
+ return { id: `start-${item.id}`, rowIndex, kind: 'wake', text: agentTurnLabel(item), createdAt: item.createdAt, turnId: item.id, bookmark: null };
60
+ }
61
+
62
+ if (item.kind === 'assistant' && bookmark !== null) {
63
+ return { id: item.id, rowIndex, kind: 'bookmark', text: item.text, createdAt: item.createdAt, turnId: item.turnId, bookmark };
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /*
70
+ * `marks` is what `bookmarkRows` answers: the bookmark drawn at a row, by row id. `earlier` is the
71
+ * outline of the thread before `rows`, and `bookmarks` the chat's, which mark the ticks of that part.
72
+ */
73
+ export function ticksOf(
74
+ rows: readonly TimelineRow[],
75
+ marks: ReadonlyMap<string, ChatBookmark> = new Map(),
76
+ earlier: readonly ChatItem[] = [],
77
+ bookmarks: readonly ChatBookmark[] = []
78
+ ): ScrubberTick[] {
79
+ const byItem = new Map(bookmarks.map((bookmark) => [bookmark.itemId, bookmark]));
80
+ const before = earlier.flatMap((item) => earlierTick(item, byItem) ?? []);
81
+
82
+ const held = rows.flatMap((row, rowIndex): ScrubberTick[] => {
47
83
  const bookmark = marks.get(row.id) ?? null;
48
84
 
49
85
  if (row.kind === 'user') {
@@ -71,6 +107,8 @@ export function ticksOf(rows: readonly TimelineRow[], marks: ReadonlyMap<string,
71
107
 
72
108
  return [{ id: row.id, rowIndex, kind: 'bookmark', text: bookmark.excerpt, createdAt: bookmark.createdAt, turnId, bookmark }];
73
109
  });
110
+
111
+ return before.length === 0 ? held : [...before, ...held];
74
112
  }
75
113
 
76
114
  /* One drawn tick: a message, or a run of messages merged once the height has fewer pixels than they need. */
@@ -2,6 +2,7 @@ import i18next from 'i18next';
2
2
  import { useEffect, useState } from 'react';
3
3
  import type { ChatAttachmentUpload } from '@adecore/agent-contracts';
4
4
  import { uploadPreviewUrl } from './attachments';
5
+ import { imageForDisplay } from './image-display';
5
6
 
6
7
  /* The edge of a composer thumbnail: a few times the 56 px it is drawn at, so a sharp screen still gets a sharp one. */
7
8
  export const THUMBNAIL_PX = 256;
@@ -53,8 +54,11 @@ function dataUrlOf(blob: Blob): Promise<string> {
53
54
  * cannot be decoded this way (an SVG, a browser without `OffscreenCanvas`) falls back to the image.
54
55
  */
55
56
  async function makeThumbnail(upload: ChatAttachmentUpload): Promise<string> {
57
+ let image: Blob | null = null;
58
+
56
59
  try {
57
- const bitmap = await createImageBitmap(new Blob([bytesOf(upload.data)], { type: upload.mime }));
60
+ image = await imageForDisplay(new Blob([bytesOf(upload.data)], { type: upload.mime }));
61
+ const bitmap = await createImageBitmap(image);
58
62
  const crop = thumbnailCrop(bitmap.width, bitmap.height);
59
63
  const canvas = new OffscreenCanvas(crop.out, crop.out);
60
64
  const context = canvas.getContext('2d');
@@ -62,7 +66,7 @@ async function makeThumbnail(upload: ChatAttachmentUpload): Promise<string> {
62
66
  if (!context) {
63
67
  bitmap.close();
64
68
 
65
- return uploadPreviewUrl(upload);
69
+ return await dataUrlOf(image);
66
70
  }
67
71
 
68
72
  context.drawImage(bitmap, crop.sx, crop.sy, crop.size, crop.size, 0, 0, crop.out, crop.out);
@@ -70,7 +74,7 @@ async function makeThumbnail(upload: ChatAttachmentUpload): Promise<string> {
70
74
 
71
75
  return await dataUrlOf(await canvas.convertToBlob({ type: 'image/png' }));
72
76
  } catch {
73
- return uploadPreviewUrl(upload);
77
+ return image === null ? uploadPreviewUrl(upload) : dataUrlOf(image);
74
78
  }
75
79
  }
76
80
 
@@ -0,0 +1,82 @@
1
+ import { useEffect, useState, type ComponentProps } from 'react';
2
+ import { imageDocumentForDisplay, imageForDisplay } from '../image-display';
3
+
4
+ type ImageProps = ComponentProps<'img'>;
5
+
6
+ /** An image with a HEIC/HEIF fallback for browsers without a native decoder. Remote sources must allow cross-origin reads. */
7
+ export function DisplayImage({ src, onError, ...props }: ImageProps) {
8
+ return <ImageSource key={typeof src === 'string' ? src : undefined} {...props} src={src} onError={onError} />;
9
+ }
10
+
11
+ function ImageSource({ src, onError, ...props }: ImageProps) {
12
+ const [retry, setRetry] = useState<string | null>(null);
13
+ const [decoded, setDecoded] = useState<{ source: string; url: string } | null>(null);
14
+ const converted = decoded !== null && decoded.source === src ? decoded.url : null;
15
+
16
+ useEffect(() => {
17
+ if (typeof src !== 'string' || retry !== src) {
18
+ return;
19
+ }
20
+
21
+ let cancelled = false;
22
+ let url: string | null = null;
23
+
24
+ const convert = async (): Promise<void> => {
25
+ try {
26
+ const embedded = await imageDocumentForDisplay(src);
27
+
28
+ if (cancelled) {
29
+ return;
30
+ }
31
+
32
+ if (embedded !== src) {
33
+ setDecoded({ source: src, url: embedded });
34
+
35
+ return;
36
+ }
37
+
38
+ const response = await fetch(src, { referrerPolicy: 'no-referrer' });
39
+
40
+ if (!response.ok) {
41
+ return;
42
+ }
43
+
44
+ const blob = await response.blob();
45
+ const image = await imageForDisplay(blob);
46
+
47
+ if (cancelled || image === blob) {
48
+ return;
49
+ }
50
+
51
+ url = URL.createObjectURL(image);
52
+ setDecoded({ source: src, url });
53
+ } catch {
54
+ // The failed image keeps the browser's alt text when it cannot be read or decoded.
55
+ }
56
+ };
57
+
58
+ void convert();
59
+
60
+ return () => {
61
+ cancelled = true;
62
+
63
+ if (url !== null) {
64
+ URL.revokeObjectURL(url);
65
+ }
66
+ };
67
+ }, [src, retry]);
68
+
69
+ return (
70
+ <img
71
+ src={converted ?? src}
72
+ {...props}
73
+ onError={(event) => {
74
+ if (typeof src === 'string' && retry !== src) {
75
+ setRetry(src);
76
+ }
77
+
78
+ onError?.(event);
79
+ }}
80
+ />
81
+ );
82
+ }
@@ -4,6 +4,7 @@ import clsx from 'clsx';
4
4
  import { 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 { DisplayImage } from './DisplayImage';
7
8
 
8
9
  const MIN_SCALE = 1;
9
10
  const MAX_SCALE = 8;
@@ -137,7 +138,7 @@ function Lightbox({
137
138
  dragRef.current = null;
138
139
  }}
139
140
  >
140
- <img
141
+ <DisplayImage
141
142
  src={src}
142
143
  alt={alt}
143
144
  draggable={false}
@@ -205,7 +206,7 @@ export function ImageThumb({ source, alt, className, aspect, actions, open: open
205
206
  style={ratio}
206
207
  onClick={() => setOpen(true)}
207
208
  >
208
- <img src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
209
+ <DisplayImage src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
209
210
  </button>
210
211
  <Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} actions={actions} />
211
212
  </>
@@ -19,6 +19,7 @@ import { useChatHost, type ChatHost, type FileRef, type ShellCodeBlockContext }
19
19
  import { useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget, useOpenFileLink } from './file-links';
20
20
  import { ErrorBoundary, FileIcon, Icon } from '@adecore/ui';
21
21
  import { useTranslation } from 'react-i18next';
22
+ import { DisplayImage } from './DisplayImage';
22
23
 
23
24
  type ShellCodeBlockReply = Pick<ShellCodeBlockContext, 'scopeId' | 'chatId' | 'itemId'> & { streaming: boolean };
24
25
 
@@ -211,14 +212,14 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
211
212
  const path = typeof src === 'string' ? localImagePath(src, cwd) : null;
212
213
 
213
214
  if (HostImage === undefined || path === null) {
214
- return <img src={src} alt={alt} />;
215
+ return <DisplayImage src={src} alt={alt} />;
215
216
  }
216
217
 
217
218
  return <HostImage path={path} alt={alt ?? ''} />;
218
219
  }
219
220
 
220
221
  function PlainImage({ src, alt }: { src?: string; alt?: string }) {
221
- return <img src={src} alt={alt} />;
222
+ return <DisplayImage src={src} alt={alt} />;
222
223
  }
223
224
 
224
225
  /* A ```mermaid fence, which draws what it says. */
@@ -45,7 +45,8 @@ import {
45
45
  STRIP_CLEARANCE_PX,
46
46
  tickOfRow,
47
47
  ticksOf,
48
- ticksWithHits
48
+ ticksWithHits,
49
+ EARLIER_ROW
49
50
  } from '../logic/scrubber';
50
51
  import {
51
52
  EMPTY_TARGET,
@@ -206,6 +207,7 @@ export function Timeline({
206
207
  const items = useChatRow(chatId, (row) => row?.structure);
207
208
  const activeTurnId = useChatRow(chatId, (row) => row?.info.activeTurnId ?? null);
208
209
  const bookmarks = useChatRow(chatId, (row) => row?.bookmarks);
210
+ const outlineBefore = useChatRow(chatId, (row) => row?.outlineBefore);
209
211
  const visuals = useChatRow(chatId, (row) => row?.visuals);
210
212
  const forkedTurns = useForkedTurns(chatId);
211
213
  const info = useChatRow(chatId, (row) => row?.info ?? null);
@@ -306,7 +308,7 @@ export function Timeline({
306
308
  // A chat that took a turn before is one whose thread has not come in yet, not one to greet.
307
309
  const welcome = !loadingHistory && welcomes && empty && (info === null || (info.usage.turns === 0 && info.activeTurnId === null));
308
310
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
309
- const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
311
+ const ticks = useMemo(() => ticksOf(rows, marks, outlineBefore, bookmarks), [rows, marks, outlineBefore, bookmarks]);
310
312
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
311
313
  const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string; options?: ItemJumpOptions } | null>(null);
312
314
  const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
@@ -613,9 +615,10 @@ export function Timeline({
613
615
  return;
614
616
  }
615
617
 
616
- if (tick.bookmark !== null) {
618
+ // A message before the rows this client holds comes in page by page until its row is there.
619
+ if (tick.bookmark !== null || tick.rowIndex === EARLIER_ROW) {
617
620
  setLanding(null);
618
- setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
621
+ setSeeking({ itemId: tick.bookmark?.itemId ?? tick.id, key: keyOf(chatId) });
619
622
 
620
623
  return;
621
624
  }
@@ -6,6 +6,7 @@ import { Spinner, useMeasuredWidth } from '@adecore/ui';
6
6
  import { useChatHost } from '../../host';
7
7
  import { useChatScope } from '../../scope';
8
8
  import { DEFAULT_HEIGHT_PX, initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
9
+ import { imageDocumentForDisplay } from '../image-display';
9
10
  import { VisualBridge } from '../visual-bridge';
10
11
  import { VisualViewportController } from '../visual-viewport';
11
12
  import { useVisualTheme } from './visual-theme';
@@ -129,7 +130,7 @@ function PageFrame({ page, title, maxHeight, fill = false, className, initialHei
129
130
  const bridge = new VisualBridge({
130
131
  frame: () => frame.contentWindow,
131
132
  visual: { maxHeight },
132
- page: page(),
133
+ page: page().then(imageDocumentForDisplay),
133
134
  theme: opening.theme,
134
135
  mayOpenLink: () => personActsIn(frame),
135
136
  openLink: (url) => visuals?.openLink(url),
@@ -3,6 +3,7 @@ export { AccountPill } from './AccountPill';
3
3
  export { AnsiOutput } from './AnsiOutput';
4
4
  export { BookmarkSubmenu } from './BookmarkSubmenu';
5
5
  export { ChatActivity, StatusIcon } from './ChatActivity';
6
+ export { DisplayImage } from './DisplayImage';
6
7
  export { CodeBlock } from './CodeBlock';
7
8
  export { Composer } from './Composer';
8
9
  export { type ComposerInputHandle, ComposerInput, type InputSelection } from './ComposerInput';
@@ -13,6 +13,7 @@ import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text
13
13
  import type { UiRendererProps } from '../render-context';
14
14
  import { useTruncated } from '../use-truncated';
15
15
  import { useHostAttachmentUrl } from '../../../../host-hooks';
16
+ import { DisplayImage } from '../../DisplayImage';
16
17
 
17
18
  // The number of every source by its node id, which the list counts in its own order.
18
19
  const SourceNumbers = createContext<ReadonlyMap<string, number>>(new Map());
@@ -153,7 +154,7 @@ export function CompareRenderer({ node, children, context }: UiRendererProps<UiP
153
154
  onValueChange={setSplit}
154
155
  label={label}
155
156
  before={
156
- <img
157
+ <DisplayImage
157
158
  src={first.url}
158
159
  alt={describe(beforeLabel)}
159
160
  draggable={false}
@@ -167,7 +168,7 @@ export function CompareRenderer({ node, children, context }: UiRendererProps<UiP
167
168
  }}
168
169
  />
169
170
  }
170
- after={<img src={second.url} alt={describe(afterLabel)} draggable={false} className="size-full object-contain select-none" />}
171
+ after={<DisplayImage src={second.url} alt={describe(afterLabel)} draggable={false} className="size-full object-contain select-none" />}
171
172
  />
172
173
  <CompareChip side="left" label={beforeLabel} />
173
174
  <CompareChip side="right" label={afterLabel} />
package/src/images.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { imageDocumentForDisplay, imageForDisplay } from './chat/image-display';
2
+ export { DisplayImage } from './chat/ui/DisplayImage';
@@ -38,6 +38,11 @@ export interface ChatState {
38
38
  * that a message woke again without a scroll reaching back to its row.
39
39
  */
40
40
  subagentsBefore?: string[];
41
+ /*
42
+ * The outline of the thread before that part (`outlineItems`), cut down and kept apart from
43
+ * `items`, so the scrubber has a tick for every message before a scroll reaches it.
44
+ */
45
+ outlineBefore?: ChatItem[];
41
46
  /* The messages marked to come back to, as the host last said; absent until it said anything. */
42
47
  bookmarks?: ChatBookmark[];
43
48
  /* The pages agents published in the chat, as the host last said; absent until it said anything. */
@@ -53,6 +58,8 @@ export interface ChatPage {
53
58
  pending: ChatItem[];
54
59
  /* The active sub-agents from before the page; absent from a host that sends none. */
55
60
  subagents?: ChatSubagentItem[];
61
+ /* The outline of the thread before the page; absent from a host that sends none. */
62
+ outline?: ChatItem[];
56
63
  }
57
64
 
58
65
  /* Rows keyed with the scope's `keyOf`, so a thread says which host it runs on. */
@@ -124,7 +131,8 @@ function stateOf(info: ChatInfo, items: ChatItem[], page?: ChatPage): ChatState
124
131
  order,
125
132
  history: page.history,
126
133
  waitingBefore: before.map((item) => item.id),
127
- subagentsBefore: subagents.map((item) => item.id)
134
+ subagentsBefore: subagents.map((item) => item.id),
135
+ outlineBefore: (page.outline ?? []).filter((item) => !(item.id in byId))
128
136
  };
129
137
  }
130
138
 
@@ -173,15 +181,16 @@ export function prependPage(state: ChatState, cursor: string, page: ChatHistoryR
173
181
  const order = [...olderIds, ...state.order];
174
182
 
175
183
  if (page.history.cursor === null) {
176
- const { history: _history, waitingBefore: _waitingBefore, subagentsBefore: _subagentsBefore, ...rest } = loaded;
184
+ const { history: _history, waitingBefore: _waitingBefore, subagentsBefore: _subagentsBefore, outlineBefore: _outlineBefore, ...rest } = loaded;
177
185
 
178
186
  return { ...rest, items, structure: items, order };
179
187
  }
180
188
 
181
189
  const waitingBefore = (state.waitingBefore ?? []).filter((id) => !olderIds.has(id));
182
190
  const subagentsBefore = (state.subagentsBefore ?? []).filter((id) => !olderIds.has(id));
191
+ const outlineBefore = (state.outlineBefore ?? []).filter((item) => !olderIds.has(item.id));
183
192
 
184
- return { ...loaded, items, structure: items, order, history: page.history, waitingBefore, subagentsBefore };
193
+ return { ...loaded, items, structure: items, order, history: page.history, waitingBefore, subagentsBefore, outlineBefore };
185
194
  }
186
195
 
187
196
  /* The requests that still wait, oldest first: those from before the page this client holds, then the thread's own. */