@adecore/agents-react 0.20.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/chat/logic/generated-image.d.ts +27 -0
  2. package/dist/chat/logic/generated-image.js +32 -0
  3. package/dist/chat/logic/timeline-target.d.ts +13 -1
  4. package/dist/chat/logic/timeline-target.js +26 -0
  5. package/dist/chat/logic/timeline.js +16 -10
  6. package/dist/chat/subagent-conversation.d.ts +2 -1
  7. package/dist/chat/subagent-conversation.js +7 -1
  8. package/dist/chat/timeline-flash.d.ts +3 -1
  9. package/dist/chat/timeline-flash.js +2 -2
  10. package/dist/chat/ui/ImageView.d.ts +8 -2
  11. package/dist/chat/ui/ImageView.js +12 -6
  12. package/dist/chat/ui/SubagentTimeline.js +6 -2
  13. package/dist/chat/ui/Timeline.js +67 -7
  14. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +23 -0
  15. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +103 -0
  16. package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
  17. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
  18. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +15 -0
  19. package/dist/chat/ui/intelligent-ui/UiReply.js +329 -0
  20. package/dist/chat/ui/intelligent-ui/block-local.d.ts +11 -0
  21. package/dist/chat/ui/intelligent-ui/block-local.js +42 -0
  22. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
  23. package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
  24. package/dist/chat/ui/intelligent-ui/node-text.d.ts +15 -0
  25. package/dist/chat/ui/intelligent-ui/node-text.js +106 -0
  26. package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
  27. package/dist/chat/ui/intelligent-ui/registry.js +51 -0
  28. package/dist/chat/ui/intelligent-ui/render-context.d.ts +53 -0
  29. package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
  30. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
  31. package/dist/chat/ui/intelligent-ui/renderers/chart.js +123 -0
  32. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
  33. package/dist/chat/ui/intelligent-ui/renderers/choices.js +45 -0
  34. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
  35. package/dist/chat/ui/intelligent-ui/renderers/content.js +59 -0
  36. package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
  37. package/dist/chat/ui/intelligent-ui/renderers/data.js +80 -0
  38. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +8 -0
  39. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +72 -0
  40. package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
  41. package/dist/chat/ui/intelligent-ui/renderers/links.js +87 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
  46. package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
  47. package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
  48. package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
  49. package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
  50. package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
  51. package/dist/chat/ui/intelligent-ui/tones.js +31 -0
  52. package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
  53. package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
  54. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
  55. package/dist/chat/ui/intelligent-ui/use-ui-links.js +126 -0
  56. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +21 -0
  57. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +272 -0
  58. package/dist/chat/ui/reply-context.d.ts +8 -0
  59. package/dist/chat/ui/reply-context.js +1 -0
  60. package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
  61. package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
  62. package/dist/chat/ui/rows/MessageRows.js +21 -4
  63. package/dist/chat/ui/rows/Rows.js +4 -2
  64. package/dist/chat/ui/rows/SubagentRow.js +5 -0
  65. package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
  66. package/dist/chat/ui/rows/WorkRows.js +4 -3
  67. package/dist/host.d.ts +11 -1
  68. package/dist/locales/en/agent-chat.json +101 -0
  69. package/dist/locales/nl/agent-chat.json +101 -0
  70. package/dist/state/chats.js +13 -0
  71. package/dist/theme.css +19 -0
  72. package/package.json +128 -7
  73. package/src/chat/logic/generated-image.ts +48 -0
  74. package/src/chat/logic/timeline-target.ts +41 -1
  75. package/src/chat/logic/timeline.ts +19 -10
  76. package/src/chat/subagent-conversation.ts +13 -2
  77. package/src/chat/timeline-flash.ts +5 -3
  78. package/src/chat/ui/ImageView.tsx +51 -8
  79. package/src/chat/ui/SubagentTimeline.tsx +24 -1
  80. package/src/chat/ui/Timeline.tsx +97 -21
  81. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +219 -0
  82. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
  83. package/src/chat/ui/intelligent-ui/UiReply.tsx +541 -0
  84. package/src/chat/ui/intelligent-ui/block-local.ts +51 -0
  85. package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
  86. package/src/chat/ui/intelligent-ui/node-text.ts +119 -0
  87. package/src/chat/ui/intelligent-ui/registry.ts +53 -0
  88. package/src/chat/ui/intelligent-ui/render-context.ts +84 -0
  89. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +319 -0
  90. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +102 -0
  91. package/src/chat/ui/intelligent-ui/renderers/content.tsx +140 -0
  92. package/src/chat/ui/intelligent-ui/renderers/data.tsx +181 -0
  93. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +144 -0
  94. package/src/chat/ui/intelligent-ui/renderers/links.tsx +158 -0
  95. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
  96. package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
  97. package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
  98. package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
  99. package/src/chat/ui/intelligent-ui/tones.ts +37 -0
  100. package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
  101. package/src/chat/ui/intelligent-ui/use-ui-links.ts +143 -0
  102. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +308 -0
  103. package/src/chat/ui/reply-context.ts +7 -0
  104. package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
  105. package/src/chat/ui/rows/MessageRows.tsx +60 -4
  106. package/src/chat/ui/rows/Rows.tsx +4 -2
  107. package/src/chat/ui/rows/SubagentRow.tsx +5 -0
  108. package/src/chat/ui/rows/WorkRows.tsx +20 -5
  109. package/src/host.ts +27 -1
  110. package/src/locales/en/agent-chat.json +101 -0
  111. package/src/locales/nl/agent-chat.json +101 -0
  112. package/src/state/chats.ts +13 -0
  113. package/src/theme.css +19 -0
@@ -1,5 +1,5 @@
1
1
  import i18next from 'i18next';
2
- import type { ChatItem, ChatSubagentResult } from '@adecore/agent-contracts';
2
+ import type { ChatApprovalItem, ChatItem, ChatQuestionItem, ChatSubagentResult } from '@adecore/agent-contracts';
3
3
  import { errorCode, isConnectionError, type ChatTransport } from '../transport';
4
4
 
5
5
  // What the panel opens on: the newest end of the conversation, which is where an agent that works is.
@@ -8,6 +8,7 @@ export const SUBAGENT_PAGE = 60;
8
8
  export interface SubagentConversationState {
9
9
  status: 'loading' | 'ready' | 'failed';
10
10
  items: ChatItem[];
11
+ pending: Array<ChatApprovalItem | ChatQuestionItem>;
11
12
  source: ChatSubagentResult['source'] | null;
12
13
  context: NonNullable<ChatSubagentResult['context']> | null;
13
14
  /* Where the page before the oldest item on screen starts; null once the start is there. */
@@ -23,6 +24,7 @@ export interface SubagentConversationState {
23
24
  export const INITIAL_CONVERSATION: SubagentConversationState = {
24
25
  status: 'loading',
25
26
  items: [],
27
+ pending: [],
26
28
  source: null,
27
29
  context: null,
28
30
  cursor: null,
@@ -59,6 +61,13 @@ export function mergeNewest(
59
61
  return { items, cursor: current.length === 0 ? pageCursor : currentCursor };
60
62
  }
61
63
 
64
+ function pendingOf(items: readonly ChatItem[]): Array<ChatApprovalItem | ChatQuestionItem> {
65
+ return items.filter(
66
+ (item): item is ChatApprovalItem | ChatQuestionItem =>
67
+ (item.kind === 'approval' && item.decision === 'pending') || (item.kind === 'question' && item.state === 'pending')
68
+ );
69
+ }
70
+
62
71
  function messageOf(error: unknown): string {
63
72
  return error instanceof Error ? error.message : i18next.t('agent-chat:subagents.noAnswer');
64
73
  }
@@ -131,7 +140,8 @@ export class SubagentConversation {
131
140
  this.set({ loadingEarlier: true });
132
141
  try {
133
142
  const page = await this.ask({ cursor, limit: SUBAGENT_PAGE });
134
- this.set({ items: [...page.items, ...this.state.items], cursor: page.history.cursor, live: page.live, loadingEarlier: false });
143
+ const items = [...page.items, ...this.state.items];
144
+ this.set({ items, pending: page.pending ?? pendingOf(items), cursor: page.history.cursor, live: page.live, loadingEarlier: false });
135
145
  } catch (error) {
136
146
  if (errorCode(error) === 'history-expired') {
137
147
  // The record was written again under this panel; what it holds now is the only truth.
@@ -167,6 +177,7 @@ export class SubagentConversation {
167
177
  this.set({
168
178
  status: 'ready',
169
179
  items: merged.items,
180
+ pending: page.pending ?? pendingOf(merged.items),
170
181
  cursor: merged.cursor,
171
182
  live: page.live,
172
183
  source: page.source,
@@ -1,4 +1,5 @@
1
1
  import { create } from 'zustand';
2
+ import type { UiReplyTarget } from './logic/timeline-target';
2
3
 
3
4
  /* The row of a thread a jump landed on: it lights up once, and a card of changes opens when the jump asked for it. */
4
5
  export interface TimelineFlash {
@@ -6,13 +7,14 @@ export interface TimelineFlash {
6
7
  key: string;
7
8
  rowId: string;
8
9
  opens: boolean;
10
+ block?: Pick<UiReplyTarget, 'blockId' | 'revision'>;
9
11
  /* A second jump to the same row counts. */
10
12
  nonce: number;
11
13
  }
12
14
 
13
15
  interface TimelineFlashStore {
14
16
  target: TimelineFlash | null;
15
- flash(key: string, rowId: string, opens: boolean): void;
17
+ flash(key: string, rowId: string, opens: boolean, block?: TimelineFlash['block']): void;
16
18
  }
17
19
 
18
20
  /* A little longer than the animation of `.chat-flash`, so a row that scrolls into view again later is neither lit nor opened a second time. */
@@ -20,9 +22,9 @@ const FLASH_MS = 2000;
20
22
 
21
23
  export const useTimelineFlash = create<TimelineFlashStore>((set, get) => ({
22
24
  target: null,
23
- flash(key, rowId, opens) {
25
+ flash(key, rowId, opens, block) {
24
26
  const nonce = (get().target?.nonce ?? 0) + 1;
25
- set({ target: { key, rowId, opens, nonce } });
27
+ set({ target: { key, rowId, opens, nonce, ...(block ? { block } : {}) } });
26
28
  const timer = setTimeout(() => {
27
29
  if (get().target?.nonce === nonce) {
28
30
  set({ target: null });
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useRef, useState, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
4
  import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
@@ -34,7 +34,19 @@ function zoomed(view: View, factor: number, pointX: number, pointY: number): Vie
34
34
  }
35
35
 
36
36
  /* An image on its own, large: the wheel and the buttons zoom, dragging pans, Escape closes. */
37
- function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string; open: boolean; onOpenChange(open: boolean): void }) {
37
+ function Lightbox({
38
+ src,
39
+ alt,
40
+ open,
41
+ onOpenChange,
42
+ actions
43
+ }: {
44
+ src: string;
45
+ alt: string;
46
+ open: boolean;
47
+ onOpenChange(open: boolean): void;
48
+ actions?: ReactNode;
49
+ }) {
38
50
  const { t } = useTranslation('agent-chat');
39
51
  const [view, setView] = useState<View>(START);
40
52
  // The alt of an image an agent read is its path; the header shows the file and keeps the path on a tooltip.
@@ -84,6 +96,7 @@ function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string;
84
96
  />
85
97
  <IconButton icon={RotateCcw} size="sm" label={t('image.reset')} disabled={view.scale === MIN_SCALE} onClick={() => setView(START)} />
86
98
  </ButtonGroup>
99
+ {actions}
87
100
  <CloseButton label={t('common.action.close')} kbd="esc" className="ml-1" dialog />
88
101
  </div>
89
102
  <div
@@ -130,16 +143,41 @@ function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string;
130
143
  );
131
144
  }
132
145
 
146
+ export interface ImageThumbProps {
147
+ source: ResourceUrl;
148
+ alt: string;
149
+ className?: string;
150
+ /* Width over height, known before the bytes: the thumbnail then fills its container's width at that ratio and holds its place while they load. */
151
+ aspect?: number;
152
+ /* Drawn in the lightbox's header before Close, such as icon buttons that act on the image. */
153
+ actions?: ReactNode;
154
+ /* Whether the lightbox is open, for a caller that opens it from a control of its own; left out, the thumbnail keeps it. */
155
+ open?: boolean;
156
+ onOpenChange?(open: boolean): void;
157
+ }
158
+
133
159
  /*
134
160
  * A thumbnail that opens the same image large. The button is the picture, so there is nothing to aim at.
135
161
  * The image is bytes where the host keeps them; until they are here a quiet square stands in for it.
136
162
  */
137
- export function ImageThumb({ source, alt, className }: { source: ResourceUrl; alt: string; className?: string }) {
138
- const [open, setOpen] = useState(false);
163
+ export function ImageThumb({ source, alt, className, aspect, actions, open: openProp, onOpenChange }: ImageThumbProps) {
164
+ const [ownOpen, setOwnOpen] = useState(false);
165
+ const open = openProp ?? ownOpen;
166
+ const setOpen = (next: boolean): void => {
167
+ setOwnOpen(next);
168
+ onOpenChange?.(next);
169
+ };
139
170
  const { url: src, failure } = source;
171
+ const ratio = aspect === undefined ? undefined : { aspectRatio: aspect };
140
172
  if (src === null) {
141
173
  return (
142
- <span className="grid size-16 place-items-center rounded-lg border border-border bg-surface-sunken text-text-faint">
174
+ <span
175
+ className={clsx(
176
+ 'grid place-items-center rounded-lg border border-border bg-surface-sunken text-text-faint',
177
+ aspect === undefined ? 'size-16' : 'w-full'
178
+ )}
179
+ style={ratio}
180
+ >
143
181
  {failure !== null && (
144
182
  <Tooltip label={failure}>
145
183
  <Icon icon={ImageOff} size={14} />
@@ -150,10 +188,15 @@ export function ImageThumb({ source, alt, className }: { source: ResourceUrl; al
150
188
  }
151
189
  return (
152
190
  <>
153
- <button className="block overflow-hidden rounded-lg border border-border" onClick={() => setOpen(true)}>
154
- <img src={src} alt={alt} className={className} />
191
+ <button
192
+ type="button"
193
+ className={clsx('block overflow-hidden rounded-lg border border-border', aspect !== undefined && 'w-full')}
194
+ style={ratio}
195
+ onClick={() => setOpen(true)}
196
+ >
197
+ <img src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
155
198
  </button>
156
- <Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} />
199
+ <Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} actions={actions} />
157
200
  </>
158
201
  );
159
202
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Bot, LoaderCircle } from 'lucide-react';
4
4
  import { deriveTimelineRows } from '../logic/timeline';
@@ -9,6 +9,8 @@ import { EMPTY_TARGET, readTimelineTarget, type TimelineTarget } from '../logic/
9
9
  import { openBelow, useSubagentTrail, type SubagentStep } from '../subagent-view';
10
10
  import { Row } from './rows/Rows';
11
11
  import { ReplyContext } from './reply-context';
12
+ import { PromptComposer } from './PromptComposer';
13
+ import { useProviders } from '../../state/providers';
12
14
  import { SubagentInfo } from './SubagentInfo';
13
15
  import { TimelineMenuPopup } from './TimelineMenu';
14
16
  import { FOLLOW_THRESHOLD_PX, rowRhythm } from './rows/row-rhythm';
@@ -27,8 +29,14 @@ const NO_TURNS = new Set<string>();
27
29
  export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
28
30
  const { t } = useTranslation('agent-chat');
29
31
  const scope = useChatScope();
32
+ const connected = useSyncExternalStore(
33
+ useCallback((changed) => scope.transport.subscribeStatus(changed), [scope]),
34
+ useCallback(() => scope.transport.status === 'open', [scope]),
35
+ () => false
36
+ );
30
37
  const { trail, show } = useSubagentTrail(chatId);
31
38
  const [state, setState] = useState<SubagentConversationState>(INITIAL_CONVERSATION);
39
+ const denyReason = useProviders((row) => row.providers.find((provider) => provider.kind === state.context?.provider)?.capabilities.denyReason === true);
32
40
  const controller = useRef<SubagentConversation | null>(null);
33
41
  const scrollRef = useRef<HTMLDivElement>(null);
34
42
  const threadRef = useRef<HTMLDivElement>(null);
@@ -167,6 +175,21 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
167
175
  </ReplyContext.Provider>
168
176
  </FileLinkContext.Provider>
169
177
  )}
178
+ {state.context?.chatId !== undefined && state.pending.length > 0 && (
179
+ <div className="chat-column-content shrink-0">
180
+ <PromptComposer
181
+ key={state.context.chatId}
182
+ chatId={state.context.chatId}
183
+ pending={state.pending}
184
+ focused={false}
185
+ disabled={!connected}
186
+ hasDraft={false}
187
+ denyReason={denyReason}
188
+ >
189
+ {null}
190
+ </PromptComposer>
191
+ </div>
192
+ )}
170
193
  </div>
171
194
  );
172
195
  }
@@ -32,8 +32,17 @@ import {
32
32
  ticksOf,
33
33
  ticksWithHits
34
34
  } from '../logic/scrubber';
35
- import { EMPTY_TARGET, readTimelineTarget, withCurrentText, type TimelineTarget } from '../logic/timeline-target';
35
+ import {
36
+ EMPTY_TARGET,
37
+ readTimelineTarget,
38
+ revealUiReplyBlock,
39
+ uiReplyJump,
40
+ withCurrentText,
41
+ type TimelineTarget,
42
+ type UiReplyTarget
43
+ } from '../logic/timeline-target';
36
44
  import { forkRefusal } from '../logic/fork';
45
+ import { UiReplyNavigationContext } from './reply-context';
37
46
  import { FindRevealContext } from './find-reveal';
38
47
  import { BookmarkMarker } from './BookmarkMarker';
39
48
  import { MessageActions } from './MessageActions';
@@ -207,10 +216,26 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
207
216
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
208
217
  const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
209
218
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
210
- const [seeking, setSeeking] = useState<string | null>(null);
219
+ const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string } | null>(null);
220
+ const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
211
221
  // A turn a jump is on its way to, which waits for the page its messages are on to come in.
212
222
  const [revealing, setRevealing] = useState<{ turnId: string; target: TurnTarget } | null>(null);
213
223
  const flash = useTimelineFlash((s) => s.target);
224
+ const revealBlock = useCallback(
225
+ (block: UiReplyTarget) => {
226
+ setLanding(null);
227
+ setSeeking({ itemId: block.itemId, block, key: keyOf(chatId) });
228
+ },
229
+ [keyOf, chatId]
230
+ );
231
+ const uiNavigation = useMemo(
232
+ () => ({
233
+ chatId,
234
+ reveal: revealBlock,
235
+ flash: flash?.key === keyOf(chatId) && flash.block ? { itemId: flash.rowId, ...flash.block, nonce: flash.nonce } : null
236
+ }),
237
+ [revealBlock, flash, keyOf, chatId]
238
+ );
214
239
 
215
240
  // Whether the thread's text clears the strip depends on the width of the view, which only the thread's own frame tells.
216
241
  useEffect(() => {
@@ -389,7 +414,8 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
389
414
  return;
390
415
  }
391
416
  if (tick.bookmark !== null) {
392
- setSeeking(tick.bookmark.itemId);
417
+ setLanding(null);
418
+ setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
393
419
  return;
394
420
  }
395
421
  followRef.current = false;
@@ -400,14 +426,34 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
400
426
  if (seeking === null) {
401
427
  return;
402
428
  }
403
- const index = rows.findIndex((row) => row.id === seeking);
429
+ if (seeking.key !== keyOf(chatId)) {
430
+ setSeeking(null);
431
+ return;
432
+ }
433
+ if (seeking.block) {
434
+ const jump = uiReplyJump(seeking.block, rows, items, turns.ids, cursor !== null);
435
+ if (jump.kind === 'earlier') {
436
+ loadEarlier();
437
+ } else if (jump.kind === 'turn') {
438
+ turns.add(jump.turnId);
439
+ } else {
440
+ setSeeking(null);
441
+ if (jump.kind === 'row') {
442
+ followRef.current = false;
443
+ virtualizer.scrollToIndex(jump.index, { align: 'start' });
444
+ setLanding({ target: seeking.block, key: seeking.key });
445
+ }
446
+ }
447
+ return;
448
+ }
449
+ const index = rows.findIndex((row) => row.id === seeking.itemId);
404
450
  if (index !== -1) {
405
451
  setSeeking(null);
406
452
  followRef.current = false;
407
453
  virtualizer.scrollToIndex(index, { align: 'start' });
408
454
  return;
409
455
  }
410
- const item = items?.[seeking];
456
+ const item = items?.[seeking.itemId];
411
457
  if (item === undefined && cursor !== null) {
412
458
  loadEarlier();
413
459
  return;
@@ -418,8 +464,36 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
418
464
  return;
419
465
  }
420
466
  setSeeking(null);
421
- }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier]);
422
- useEffect(() => registerItemJumper(keyOf(chatId), setSeeking), [keyOf, chatId]);
467
+ }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
468
+ useEffect(
469
+ () =>
470
+ registerItemJumper(keyOf(chatId), (itemId) => {
471
+ setLanding(null);
472
+ setSeeking({ itemId, key: keyOf(chatId) });
473
+ }),
474
+ [keyOf, chatId]
475
+ );
476
+ useLayoutEffect(() => {
477
+ if (!landing || landing.key !== keyOf(chatId)) {
478
+ return;
479
+ }
480
+ let frame = 0;
481
+ let attempts = 0;
482
+ const reveal = () => {
483
+ const scroller = scrollRef.current;
484
+ if (scroller && revealUiReplyBlock(scroller, landing.target)) {
485
+ useTimelineFlash.getState().flash(keyOf(chatId), landing.target.itemId, false, landing.target);
486
+ setLanding(null);
487
+ } else if (++attempts < 30) {
488
+ // Virtual rows mount after scrollToIndex updates the visible range.
489
+ frame = requestAnimationFrame(reveal);
490
+ } else {
491
+ setLanding(null);
492
+ }
493
+ };
494
+ frame = requestAnimationFrame(reveal);
495
+ return () => cancelAnimationFrame(frame);
496
+ }, [landing, keyOf, chatId]);
423
497
 
424
498
  useLayoutEffect(() => {
425
499
  if (revealing === null) {
@@ -562,7 +636,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
562
636
  top={virtualRow.start}
563
637
  ref={virtualizer.measureElement}
564
638
  >
565
- {flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && (
639
+ {flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && !flash.block && (
566
640
  <span
567
641
  key={flash.nonce}
568
642
  className="chat-flash pointer-events-none absolute inset-0 rounded-lg"
@@ -574,19 +648,21 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
574
648
  <ReplyHeader chatId={chatId} at={header.at} />
575
649
  </div>
576
650
  )}
577
- <FindRevealContext.Provider value={chatFind.reveal}>
578
- <MarkableRow row={row} chatId={chatId} bookmark={marks.get(row.id) ?? null}>
579
- <Row
580
- row={row}
581
- chatId={chatId}
582
- toggleGroup={groups.toggle}
583
- toggleTurn={turns.toggle}
584
- toggleSubagent={subagents.toggle}
585
- openSubagent={openSubagent}
586
- openConversation={openConversation}
587
- />
588
- </MarkableRow>
589
- </FindRevealContext.Provider>
651
+ <UiReplyNavigationContext.Provider value={uiNavigation}>
652
+ <FindRevealContext.Provider value={chatFind.reveal}>
653
+ <MarkableRow row={row} chatId={chatId} bookmark={marks.get(row.id) ?? null}>
654
+ <Row
655
+ row={row}
656
+ chatId={chatId}
657
+ toggleGroup={groups.toggle}
658
+ toggleTurn={turns.toggle}
659
+ toggleSubagent={subagents.toggle}
660
+ openSubagent={openSubagent}
661
+ openConversation={openConversation}
662
+ />
663
+ </MarkableRow>
664
+ </FindRevealContext.Provider>
665
+ </UiReplyNavigationContext.Provider>
590
666
  </RowContainer>
591
667
  );
592
668
  })}
@@ -0,0 +1,219 @@
1
+ import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import type { UiViewNode } from '@adecore/intelligent-ui';
5
+ import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
6
+ import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
7
+ import { formatAgo, formatMoment } from '@adecore/ui/format';
8
+ import { uiBlockHead, uiReasonText } from './node-text';
9
+ import { UiHeadContext, type UiLiveStatus } from './render-context';
10
+ import { useLater } from './use-later';
11
+
12
+ /* Something the compiler repaired, such as a prop it dropped; never an error to a person. */
13
+ export interface UiBlockFix {
14
+ code: string;
15
+ message: string;
16
+ }
17
+
18
+ export interface UiBlockFooterProps {
19
+ live?: UiLiveStatus | null;
20
+ /* The label of the Choice that answered the block, and when, in milliseconds since the epoch. */
21
+ answered?: { label: string; at: number } | null;
22
+ fixes?: readonly UiBlockFix[];
23
+ /* Why the whole block is drawn as its text, such as that it is too large. */
24
+ shownAsText?: string | null;
25
+ }
26
+
27
+ const NO_FIXES: readonly UiBlockFix[] = [];
28
+ const READING_SPINNER_MS = 1000;
29
+
30
+ /* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
31
+ function LivePart({ live }: { live: UiLiveStatus }) {
32
+ const { t } = useTranslation('agent-chat');
33
+ const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
34
+ const { readAt } = live;
35
+ const fresh = live.state === 'fresh' || live.state === 'reading';
36
+ const ago = useTickingText(() => {
37
+ if (readAt === null) {
38
+ return '';
39
+ }
40
+ const time = formatAgo(Date.now() - readAt);
41
+ return fresh ? t('blocks.live.read', { time }) : time;
42
+ });
43
+ const source = live.source ?? live.sources[0] ?? '';
44
+ const icon = slow ? (
45
+ <Spinner size={12} />
46
+ ) : live.state === 'failed' ? (
47
+ <Icon icon={TriangleAlert} size={12} className="text-status-needs-you" />
48
+ ) : live.state === 'refused' ? (
49
+ <Icon icon={Lock} size={12} />
50
+ ) : (
51
+ <Icon icon={Radio} size={12} className="text-status-idle" />
52
+ );
53
+ const words =
54
+ live.state === 'failed'
55
+ ? t('blocks.live.failed', { source })
56
+ : live.state === 'refused'
57
+ ? t('blocks.live.refused', { source })
58
+ : t('blocks.live.label');
59
+ const reason = live.state === 'failed' || live.state === 'refused' ? uiReasonText(t, live.code, live.reason) : undefined;
60
+ const hint = reason !== undefined ? reason : live.sources.length > 0 ? t('blocks.live.sources', { sources: live.sources.join(', ') }) : null;
61
+ const body = (
62
+ <span tabIndex={hint === null ? undefined : 0} className="flex items-center gap-1.5">
63
+ {icon}
64
+ <span>{words}</span>
65
+ {readAt !== null && live.state !== 'refused' && (
66
+ <>
67
+ <span aria-hidden>·</span>
68
+ <time dateTime={new Date(readAt).toISOString()}>
69
+ <span ref={ago} className="tabular-nums" />
70
+ </time>
71
+ </>
72
+ )}
73
+ {readAt === null && fresh && (
74
+ <>
75
+ <span aria-hidden>·</span>
76
+ <span>{t('blocks.live.notRead')}</span>
77
+ </>
78
+ )}
79
+ </span>
80
+ );
81
+ return hint === null ? body : <Tooltip label={hint}>{body}</Tooltip>;
82
+ }
83
+
84
+ /*
85
+ * The line under a block, only when it has something to say: live, answered, repaired or drawn as
86
+ * text, in that order. A hairline of the soft border runs the full width of the card over it.
87
+ */
88
+ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
89
+ const { t } = useTranslation('agent-chat');
90
+ const [fixesOpen, setFixesOpen] = useState(false);
91
+ const listId = useId();
92
+ if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
93
+ return null;
94
+ }
95
+ return (
96
+ <div className="-mx-2.25 mt-2.25 -mb-2.25 flex flex-col gap-1.5 border-t border-border-soft px-4.25 py-2 text-xs text-text-faint">
97
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
98
+ {live !== null && <LivePart live={live} />}
99
+ {answered !== null && (
100
+ <span className="flex items-center gap-1.5">
101
+ <span>{t('blocks.answered', { label: answered.label })}</span>
102
+ <span aria-hidden>·</span>
103
+ <time dateTime={new Date(answered.at).toISOString()}>{formatMoment(answered.at)}</time>
104
+ </span>
105
+ )}
106
+ {fixes.length > 0 && (
107
+ <button
108
+ type="button"
109
+ aria-expanded={fixesOpen}
110
+ aria-controls={listId}
111
+ className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
112
+ onClick={() => setFixesOpen(!fixesOpen)}
113
+ >
114
+ <Icon icon={Wrench} size={12} />
115
+ {t('blocks.fixes', { count: fixes.length })}
116
+ </button>
117
+ )}
118
+ {shownAsText !== null && (
119
+ <span className="flex items-center gap-1.5">
120
+ <span>{t('blocks.shownAsText')}</span>
121
+ <span aria-hidden>·</span>
122
+ <span>{shownAsText}</span>
123
+ </span>
124
+ )}
125
+ </div>
126
+ {fixes.length > 0 && (
127
+ <ul id={listId} hidden={!fixesOpen} className="flex flex-col gap-0.5 text-text-muted">
128
+ {fixes.map((fix, index) => (
129
+ <li key={index} className="flex gap-2">
130
+ <span className="shrink-0 font-mono">{fix.code}</span>
131
+ <span className="min-w-0">{fix.message}</span>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ )}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ export interface UiBlockFrameProps extends UiBlockFooterProps {
141
+ /* The evaluated top-level nodes, read for the Summary that heads the block. */
142
+ nodes: readonly UiViewNode[];
143
+ phase: 'streaming' | 'final';
144
+ /* The rendered nodes, or the markdown of the whole block when it is shown as text. */
145
+ children: ReactNode;
146
+ className?: string;
147
+ }
148
+
149
+ const RELEASE_MS = 200;
150
+
151
+ /*
152
+ * The tallest the card has been, while `active`: a streaming block grows and never shrinks until its
153
+ * last compile. Then the card eases down to its own height, or drops there for a person who asked
154
+ * for less motion.
155
+ */
156
+ function useRisingFloor(active: boolean): [RefObject<HTMLDivElement | null>, number] {
157
+ const ref = useRef<HTMLDivElement>(null);
158
+ const held = useRef(0);
159
+ const [floor, setFloor] = useState(0);
160
+ useLayoutEffect(() => {
161
+ const element = ref.current;
162
+ if (element === null) {
163
+ return;
164
+ }
165
+ if (active) {
166
+ const observer = new ResizeObserver(() => {
167
+ const height = Math.ceil(element.getBoundingClientRect().height);
168
+ held.current = Math.max(held.current, height);
169
+ setFloor(held.current);
170
+ });
171
+ observer.observe(element);
172
+ return () => {
173
+ observer.disconnect();
174
+ setFloor(0);
175
+ };
176
+ }
177
+ // The final render already let go of the floor, so the card measures its own height here.
178
+ const from = held.current;
179
+ const to = element.getBoundingClientRect().height;
180
+ held.current = 0;
181
+ const reduced = element.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
182
+ if (from - to < 1 || reduced) {
183
+ return;
184
+ }
185
+ const release = element.animate([{ minHeight: `${from}px` }, { minHeight: `${to}px` }], { duration: RELEASE_MS, easing: 'ease-out' });
186
+ return () => release.cancel();
187
+ }, [active]);
188
+ return [ref, floor];
189
+ }
190
+
191
+ /*
192
+ * The card of one block, with its footer. Named by its Summary and busy while it streams, but never
193
+ * live: reading out every compile would be noise. A node fades in as it arrives, without a stagger,
194
+ * and for a person who asked for less motion it only fades; a node inside another only fades
195
+ * (`chat-ui-nodes`). Until the first node a skeleton holds the place of the head.
196
+ */
197
+ export function UiBlockFrame({ nodes, phase, children, className, ...footer }: UiBlockFrameProps) {
198
+ const { t } = useTranslation('agent-chat');
199
+ const streaming = phase === 'streaming';
200
+ const head = uiBlockHead(nodes);
201
+ const [ref, floor] = useRisingFloor(streaming);
202
+ return (
203
+ <UiHeadContext value={head?.id ?? null}>
204
+ <div
205
+ ref={ref}
206
+ role="group"
207
+ aria-label={head === null || head.label === '' ? t('blocks.label') : head.label}
208
+ aria-busy={streaming || undefined}
209
+ className={clsx('flex min-h-11 flex-col rounded-2xl border border-border bg-surface-raised p-2.25', className)}
210
+ style={streaming && floor > 0 ? { minHeight: floor } : undefined}
211
+ >
212
+ <div className="flex flex-col gap-3 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5">
213
+ {nodes.length === 0 && streaming ? <Skeleton className="mx-2 my-1 w-2/5" /> : children}
214
+ </div>
215
+ <UiBlockFooter {...footer} />
216
+ </div>
217
+ </UiHeadContext>
218
+ );
219
+ }
@@ -0,0 +1,21 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { Markdown } from '../Markdown';
3
+
4
+ /* Why a part is drawn as its text: a component this version does not know, or one it could not draw. */
5
+ export type UiFallbackProblem = { kind: 'unknown'; component: string } | { kind: 'failed' };
6
+
7
+ /*
8
+ * One part of a block as the markdown it stands for, in a quiet surface with a line that says why.
9
+ * A person sees it as a repair, never as an error, so nothing here is red.
10
+ */
11
+ export function UiFallbackPart({ fallback, problem }: { fallback: string; problem: UiFallbackProblem }) {
12
+ const { t } = useTranslation('agent-chat');
13
+ return (
14
+ <div className="flex flex-col gap-1 rounded-md bg-surface-hover p-2">
15
+ <span className="text-xs text-text-faint">
16
+ {problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed')}
17
+ </span>
18
+ {fallback !== '' && <Markdown text={fallback} fileLinks={false} />}
19
+ </div>
20
+ );
21
+ }