@adecore/agents-react 0.35.5 → 0.37.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 (58) hide show
  1. package/dist/chat/chat-client.d.ts +5 -0
  2. package/dist/chat/chat-client.js +91 -20
  3. package/dist/chat/index.d.ts +1 -0
  4. package/dist/chat/index.js +2 -1
  5. package/dist/chat/logic/picker-entries.js +11 -6
  6. package/dist/chat/logic/scrubber.d.ts +4 -2
  7. package/dist/chat/logic/scrubber.js +3 -3
  8. package/dist/chat/pending-prompts.d.ts +15 -0
  9. package/dist/chat/pending-prompts.js +93 -0
  10. package/dist/chat/reading-width.js +6 -2
  11. package/dist/chat/ui/Pickers.js +6 -3
  12. package/dist/chat/ui/PromptComposer.d.ts +2 -2
  13. package/dist/chat/ui/PromptComposer.js +92 -98
  14. package/dist/chat/ui/RunSettings.js +2 -2
  15. package/dist/chat/ui/SubagentTimeline.js +128 -182
  16. package/dist/chat/ui/Timeline.js +86 -60
  17. package/dist/chat/ui/code-lines.d.ts +5 -0
  18. package/dist/chat/ui/code-lines.js +37 -0
  19. package/dist/chat/ui/composer/ComposerControls.js +1 -1
  20. package/dist/chat/ui/composer/use-pending-requests.d.ts +2 -2
  21. package/dist/chat/ui/composer/use-pending-requests.js +29 -37
  22. package/dist/chat/ui/highlighter.d.ts +1 -1
  23. package/dist/chat/ui/highlighter.js +4 -2
  24. package/dist/chat/ui/rows/MessageRows.js +1 -1
  25. package/dist/locales/en/agent-chat.d.ts +1 -0
  26. package/dist/locales/en/agent-chat.js +1 -0
  27. package/dist/locales/nl/agent-chat.d.ts +1 -0
  28. package/dist/locales/nl/agent-chat.js +1 -0
  29. package/dist/prompts/logic/subjects.d.ts +5 -0
  30. package/dist/prompts/ui/PromptCard.js +1 -1
  31. package/dist/prompts/ui/PromptView.js +104 -95
  32. package/dist/state/chats.d.ts +4 -0
  33. package/dist/state/chats.js +84 -48
  34. package/dist/theme.css +1 -1
  35. package/package.json +8 -8
  36. package/src/chat/chat-client.ts +92 -21
  37. package/src/chat/index.ts +2 -0
  38. package/src/chat/logic/picker-entries.ts +6 -4
  39. package/src/chat/logic/scrubber.ts +8 -6
  40. package/src/chat/pending-prompts.ts +108 -0
  41. package/src/chat/reading-width.ts +9 -2
  42. package/src/chat/ui/Pickers.tsx +3 -6
  43. package/src/chat/ui/PromptComposer.tsx +10 -7
  44. package/src/chat/ui/RunSettings.tsx +2 -2
  45. package/src/chat/ui/SubagentTimeline.tsx +1 -24
  46. package/src/chat/ui/Timeline.tsx +66 -40
  47. package/src/chat/ui/code-lines.ts +37 -0
  48. package/src/chat/ui/composer/ComposerControls.tsx +1 -1
  49. package/src/chat/ui/composer/use-pending-requests.ts +13 -21
  50. package/src/chat/ui/highlighter.ts +4 -3
  51. package/src/chat/ui/rows/MessageRows.tsx +1 -1
  52. package/src/locales/en/agent-chat.ts +1 -0
  53. package/src/locales/nl/agent-chat.ts +1 -0
  54. package/src/prompts/logic/subjects.ts +4 -1
  55. package/src/prompts/ui/PromptCard.tsx +1 -1
  56. package/src/prompts/ui/PromptView.tsx +7 -1
  57. package/src/state/chats.ts +63 -18
  58. package/src/theme.css +1 -1
@@ -12,7 +12,7 @@ import {
12
12
  type ReactNode
13
13
  } from 'react';
14
14
  import { useTranslation } from 'react-i18next';
15
- import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
15
+ import { defaultRangeExtractor, useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
16
16
  import clsx from 'clsx';
17
17
  import { LoaderCircle } from 'lucide-react';
18
18
  import type { ChatBookmark } from '@adecore/agent-contracts';
@@ -29,7 +29,6 @@ import {
29
29
  setTimelineAtEnd,
30
30
  wantsEarlier,
31
31
  type ItemJumpOptions,
32
- type ReadingAnchor,
33
32
  type TurnTarget
34
33
  } from '../timeline-scroll';
35
34
  import { useTimelineFlash } from '../timeline-flash';
@@ -193,6 +192,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
193
192
  const visuals = useChatRow(chatId, (row) => row?.visuals);
194
193
  const forkedTurns = useForkedTurns(chatId);
195
194
  const info = useChatRow(chatId, (row) => row?.info ?? null);
195
+ const historyPending = useChatRow(chatId, (row) => row?.historyPending === true);
196
196
  const cursor = useChatRow(chatId, (row) => row?.history?.cursor ?? null);
197
197
  const scope = useChatScope();
198
198
  const { id: scopeId, keyOf } = scope;
@@ -226,9 +226,6 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
226
226
  const [frame, setFrame] = useState({ width: 0, height: 0, column: false });
227
227
  const [loadingEarlier, setLoadingEarlier] = useState(false);
228
228
  const loadingEarlierRef = useRef(false);
229
- // The row being read and where it stood, taken after every render, so a page going in above puts it back.
230
- const anchorRef = useRef<ReadingAnchor | null>(null);
231
- const headRef = useRef<string | undefined>(undefined);
232
229
 
233
230
  const loadEarlier = useCallback(() => {
234
231
  if (cursor === null || loadingEarlierRef.current) {
@@ -272,9 +269,10 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
272
269
  );
273
270
  const welcomes = useHostWelcome(chatId);
274
271
 
272
+ const loadingHistory = historyPending && ((info !== null && (info.usage.turns > 0 || info.activeTurnId !== null)) || (order?.length ?? 0) > 0);
275
273
  const empty = rows.length === 0;
276
274
  // A chat that took a turn before is one whose thread has not come in yet, not one to greet.
277
- const welcome = welcomes && empty && (info === null || (info.usage.turns === 0 && info.activeTurnId === null));
275
+ const welcome = !loadingHistory && welcomes && empty && (info === null || (info.usage.turns === 0 && info.activeTurnId === null));
278
276
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
279
277
  const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
280
278
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
@@ -300,7 +298,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
300
298
  );
301
299
 
302
300
  // Whether the thread's text clears the strip depends on the width of the view, which only the thread's own frame tells.
303
- useEffect(() => {
301
+ useLayoutEffect(() => {
304
302
  const element = frameRef.current;
305
303
  if (element === null || typeof ResizeObserver === 'undefined') {
306
304
  return;
@@ -350,14 +348,34 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
350
348
  [chatId]
351
349
  );
352
350
 
351
+ const [measuredThread, setMeasuredThread] = useState<string | null>(null);
352
+ const initialLayout = measuredThread !== keyOf(chatId);
353
+ const [, settleLayout] = useReducer((count: number) => count + 1, 0);
354
+
355
+ // Changing this callback invalidates every cached measurement, including on a scroll.
356
+ const getItemKey = useCallback((index: number) => rows[index]!.id, [rows]);
357
+
353
358
  // Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
354
359
  // oxlint-disable-next-line react/incompatible-library
355
360
  const virtualizer = useVirtualizer({
356
- count: rows.length,
361
+ count: loadingHistory ? 0 : rows.length,
357
362
  getScrollElement: () => scrollRef.current,
358
363
  estimateSize: () => ESTIMATED_ROW_PX,
359
- getItemKey: (index) => rows[index]!.id,
364
+ getItemKey,
360
365
  overscan: 8,
366
+ anchorTo: 'end',
367
+ // Include the end before the first pin, so its actual heights are known before revealing it.
368
+ rangeExtractor: (range) => {
369
+ const indexes = defaultRangeExtractor(range);
370
+ if (!initialLayout) {
371
+ return indexes;
372
+ }
373
+ const tail = Math.max(8, Math.ceil(frame.height / ESTIMATED_ROW_PX));
374
+ return [...new Set([...indexes, ...Array.from({ length: Math.min(tail, range.count) }, (_, index) => range.count - 1 - index)])].sort(
375
+ (left, right) => left - right
376
+ );
377
+ },
378
+ measureElement: (element, entry) => Math.round(entry?.borderBoxSize?.[0]?.blockSize ?? (element as HTMLElement).offsetHeight),
361
379
  scrollPaddingEnd: coveredHeight
362
380
  });
363
381
 
@@ -368,6 +386,24 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
368
386
  * A word that does not add a line moves nothing, so the thread does not listen to the words.
369
387
  */
370
388
  const totalSize = virtualizer.getTotalSize();
389
+ const virtualRows = virtualizer.getVirtualItems();
390
+ useLayoutEffect(() => {
391
+ if (!initialLayout || empty || loadingHistory || frame.height === 0) {
392
+ return;
393
+ }
394
+ // Ref measurements can change offsets after render. Commit those offsets before the first paint.
395
+ const measured = virtualizer.getVirtualItems();
396
+ if (
397
+ virtualRows.length !== measured.length ||
398
+ virtualRows.some((row, index) => row.key !== measured[index]?.key || row.start !== measured[index]?.start || row.size !== measured[index]?.size)
399
+ ) {
400
+ settleLayout();
401
+ return;
402
+ }
403
+ if (measured.at(-1)?.index === rows.length - 1) {
404
+ setMeasuredThread(keyOf(chatId));
405
+ }
406
+ }, [initialLayout, empty, loadingHistory, frame.height, virtualRows, virtualizer, rows.length, keyOf, chatId]);
371
407
  useLayoutEffect(() => {
372
408
  const element = scrollRef.current;
373
409
  if (!followRef.current || element === null) {
@@ -378,27 +414,6 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
378
414
  scrollGeometry.current = { top: element.scrollTop, height: element.scrollHeight, viewport: element.clientHeight };
379
415
  }, [rows.length, totalSize, composerHeight, frame.height]);
380
416
 
381
- // A page that went in above moves every row down by what it measures; the reader stays on the row they were on.
382
- useLayoutEffect(() => {
383
- const element = scrollRef.current;
384
- const head = order?.[0];
385
- const moved = head !== headRef.current;
386
- headRef.current = head;
387
- if (element === null) {
388
- return;
389
- }
390
- const anchor = anchorRef.current;
391
- const index = moved && anchor !== null && !followRef.current ? rows.findIndex((row) => row.id === anchor.id) : -1;
392
- if (anchor !== null && index !== -1) {
393
- // The total measures again what this commit's rows reported; the cache alone lags behind them.
394
- virtualizer.getTotalSize();
395
- element.scrollTop = virtualizer.measurementsCache[index]!.start - anchor.offset;
396
- return;
397
- }
398
- const row = virtualizer.getVirtualItemForOffset(element.scrollTop);
399
- anchorRef.current = row === undefined ? null : { id: String(row.key), offset: row.start - element.scrollTop };
400
- });
401
-
402
417
  // Close to the top, the page before goes in; again after each page that left too little above the reader.
403
418
  useEffect(() => {
404
419
  const element = scrollRef.current;
@@ -414,12 +429,15 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
414
429
  const readingWidth = useChatPreferences((preferences) => preferences.readingWidth);
415
430
  const paddingLeft = threadPaddingLeft(frame.width, showsScrubber, readingMeasure(readingWidth));
416
431
 
417
- /*
418
- * Where each message starts, from the virtualizer's measurements (an estimate for a row it never
419
- * drew), so reading the scroll position costs no layout.
420
- */
432
+ // The virtualizer materializes measurements on access; binary searches need only a few message starts.
421
433
  const measurements = virtualizer.measurementsCache;
422
- const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
434
+ const starts = {
435
+ length: ticks.length,
436
+ at: (index: number): number | undefined => {
437
+ const tick = ticks.at(index);
438
+ return tick === undefined ? undefined : (measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
439
+ }
440
+ };
423
441
  const scrollTop = virtualizer.scrollOffset ?? 0;
424
442
  const viewport = virtualizer.scrollRect?.height ?? 0;
425
443
  const visibleHeight = Math.max(0, viewport - coveredHeight);
@@ -507,6 +525,9 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
507
525
  setSeeking(null);
508
526
  return;
509
527
  }
528
+ if (loadingHistory) {
529
+ return;
530
+ }
510
531
  if (seeking.block) {
511
532
  const jump = uiReplyJump(seeking.block, rows, items, turns.ids, cursor !== null);
512
533
  if (jump.kind === 'earlier') {
@@ -547,8 +568,8 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
547
568
  }
548
569
  setSeeking(null);
549
570
  seeking.options?.onMissing?.();
550
- }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
551
- const waitingForThread = seeking !== null && items === undefined;
571
+ }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId, loadingHistory]);
572
+ const waitingForThread = seeking !== null && (items === undefined || loadingHistory);
552
573
  useEffect(() => {
553
574
  if (!waitingForThread) {
554
575
  return;
@@ -724,15 +745,20 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
724
745
  setTarget(readTimelineTarget(e.target as HTMLElement, threadRef.current, withCurrentText(rows, fullItems())))
725
746
  }
726
747
  >
727
- {welcome ? (
748
+ {loadingHistory ? (
749
+ <div className="flex grow items-center justify-center gap-2 text-sm text-text-muted" role="status">
750
+ <Icon icon={LoaderCircle} size={16} className="animate-spin" />
751
+ <span>{t('timeline.loading')}</span>
752
+ </div>
753
+ ) : welcome ? (
728
754
  <WelcomeGreeting chatId={chatId} />
729
755
  ) : empty ? (
730
756
  <EmptyThread chatId={chatId} />
731
757
  ) : (
732
- <div className="relative w-full shrink-0" style={{ height: virtualizer.getTotalSize() }}>
758
+ <div className="relative w-full shrink-0" style={{ height: totalSize, visibility: initialLayout ? 'hidden' : undefined }}>
733
759
  <UiReplyNavigationContext.Provider value={uiNavigation}>
734
760
  <FindRevealContext.Provider value={chatFind.reveal}>
735
- {virtualizer.getVirtualItems().map((virtualRow) => {
761
+ {virtualRows.map((virtualRow) => {
736
762
  const row = rows[virtualRow.index]!;
737
763
  return (
738
764
  <ThreadRow
@@ -9,6 +9,43 @@ export interface CodeToken {
9
9
  /* Tokenizes `code` as lines, carrying on from `state` when there is one, and answers the state after it. */
10
10
  export type Tokenize<State> = (code: string, state: State | undefined) => { lines: CodeToken[][]; state: State | undefined };
11
11
 
12
+ // Virtual rows unmount offscreen; share this budget across their languages and themes.
13
+ const TOKEN_CACHE_BUDGET = 4 * 1024 * 1024;
14
+ const TOKEN_CACHE_ENTRIES = 128;
15
+
16
+ export class TokenCache<State> {
17
+ private readonly entries = new Map<string, { result: ReturnType<Tokenize<State>>; cost: number }>();
18
+ private size = 0;
19
+
20
+ wrap(namespace: string, tokenize: Tokenize<State>): Tokenize<State> {
21
+ return (code, state) => {
22
+ if (state !== undefined) {
23
+ return tokenize(code, state);
24
+ }
25
+ const key = `${namespace}\0${code}`;
26
+ const known = this.entries.get(key);
27
+ if (known !== undefined) {
28
+ this.entries.delete(key);
29
+ this.entries.set(key, known);
30
+ return known.result;
31
+ }
32
+ const result = tokenize(code, state);
33
+ // Account for tokens too, so dense code cannot fill the cache with tiny strings.
34
+ const cost = key.length * 2 + result.lines.reduce((sum, line) => sum + line.length * 64, 0);
35
+ if (cost <= TOKEN_CACHE_BUDGET) {
36
+ while (this.entries.size > 0 && (this.size + cost > TOKEN_CACHE_BUDGET || this.entries.size >= TOKEN_CACHE_ENTRIES)) {
37
+ const oldest = this.entries.keys().next().value!;
38
+ this.size -= this.entries.get(oldest)!.cost;
39
+ this.entries.delete(oldest);
40
+ }
41
+ this.entries.set(key, { result, cost });
42
+ this.size += cost;
43
+ }
44
+ return result;
45
+ };
46
+ }
47
+ }
48
+
12
49
  /*
13
50
  * The lines of a code block that grows at the end. A line that ended keeps the tokens it got, and
14
51
  * the grammar state after it is where the next line starts, so a delta tokenizes the lines that are
@@ -76,7 +76,7 @@ export function ComposerControls({
76
76
  <Tooltip label={t('composer.attach')} name>
77
77
  <button
78
78
  type="button"
79
- className="grid h-8 w-8 shrink-0 place-items-center rounded-full border border-border text-text-muted hover:bg-surface-hover hover:text-text disabled:opacity-50"
79
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-full border border-border text-text-muted hover:bg-surface-hover-edged hover:text-text disabled:opacity-50"
80
80
  disabled={!writable}
81
81
  onClick={() => fileInputRef.current?.click()}
82
82
  >
@@ -1,23 +1,15 @@
1
- import { useMemo } from 'react';
2
- import type { ChatApprovalItem, ChatQuestionItem } from '@adecore/agent-contracts';
3
- import { useChatRow, waitingRequestsOf } from '../../../state/chats';
1
+ import { useEffect, useState } from 'react';
2
+ import { useChatScope } from '../../../scope';
3
+ import type { PromptSubject } from '../../../prompts/logic/subjects';
4
+ import { ChatPromptFeed } from '../../pending-prompts';
4
5
 
5
- /* The approvals and then the questions the chat waits on, which take the composer's place until answered. */
6
- export function usePendingRequests(chatId: string): Array<ChatApprovalItem | ChatQuestionItem> {
7
- // The structure, which a delta leaves alone. The prompts and the requests are items of their own.
8
- const structure = useChatRow(chatId, (row) => row?.structure);
9
- const order = useChatRow(chatId, (row) => row?.order);
10
- const waitingBefore = useChatRow(chatId, (row) => row?.waitingBefore);
11
- return useMemo(() => {
12
- const approvals: ChatApprovalItem[] = [];
13
- const questions: ChatQuestionItem[] = [];
14
- for (const item of waitingRequestsOf(structure && order ? { structure, order, waitingBefore } : undefined)) {
15
- if (item.kind === 'approval') {
16
- approvals.push(item);
17
- } else {
18
- questions.push(item);
19
- }
20
- }
21
- return [...approvals, ...questions];
22
- }, [structure, order, waitingBefore]);
6
+ export function usePendingRequests(chatId: string): PromptSubject[] {
7
+ const scope = useChatScope();
8
+ const [subjects, setSubjects] = useState<PromptSubject[]>([]);
9
+ useEffect(() => {
10
+ const feed = new ChatPromptFeed(scope, chatId, setSubjects);
11
+ feed.start();
12
+ return () => feed.dispose();
13
+ }, [scope, chatId]);
14
+ return subjects;
23
15
  }
@@ -1,6 +1,6 @@
1
1
  import type { CSSProperties } from 'react';
2
2
  import type { BundledLanguage, BundledTheme, Highlighter, ThemeRegistration } from 'shiki';
3
- import type { CodeToken, Tokenize } from './code-lines';
3
+ import { TokenCache, type CodeToken, type Tokenize } from './code-lines';
4
4
  import { useChatHost, type ChatHost } from '../../host';
5
5
  import { shikiThemeOf } from './code-theme';
6
6
 
@@ -42,6 +42,7 @@ function createCodeHighlighter(source: () => Promise<Highlighter>): CodeHighligh
42
42
  const languageLoads = new Map<string, Promise<void>>();
43
43
  const themes = new Set<string>();
44
44
  const themeLoads = new Map<string, Promise<void>>();
45
+ const tokens = new TokenCache<GrammarState>();
45
46
  const tokenizers = new Map<string, { tokenize: Tokenize<GrammarState>; fg: string | undefined }>();
46
47
 
47
48
  const load = (): Promise<Highlighter> => {
@@ -104,10 +105,10 @@ function createCodeHighlighter(source: () => Promise<Highlighter>): CodeHighligh
104
105
  if (!entry) {
105
106
  const options = { lang: resolved as BundledLanguage, theme };
106
107
  entry = {
107
- tokenize: (code, state) => {
108
+ tokenize: tokens.wrap(key, (code, state) => {
108
109
  const result = loaded.codeToTokens(code, state ? { ...options, grammarState: state } : options);
109
110
  return { lines: result.tokens, state: result.grammarState };
110
- },
111
+ }),
111
112
  fg: loaded.getTheme(theme).fg
112
113
  };
113
114
  tokenizers.set(key, entry);
@@ -65,7 +65,7 @@ function AttachmentLink({ chatId, attachment }: { chatId: string; attachment: Ch
65
65
  target="_blank"
66
66
  rel="noreferrer"
67
67
  aria-disabled={url === null}
68
- className="flex h-28 w-36 flex-col rounded-xl border border-border bg-surface-raised bg-clip-padding p-2.5 text-left hover:bg-surface-hover"
68
+ className="flex h-28 w-36 flex-col rounded-xl border border-border bg-surface-raised bg-clip-padding p-2.5 text-left hover:bg-surface-hover-edged"
69
69
  >
70
70
  <span className="flex h-6 min-w-6 items-center justify-center self-start rounded-md border border-border px-1.5 text-xs font-medium text-text-muted">
71
71
  {badge ?? <Icon icon={File} size={12} />}
@@ -233,6 +233,7 @@ export default {
233
233
  canRead: 'It can read',
234
234
  worksIn: 'It works in'
235
235
  },
236
+ loading: 'Loading conversation',
236
237
  loadingEarlier: 'Loading earlier messages',
237
238
  stripFailed: 'Could not show the messages',
238
239
  cardFailed: 'Could not show this card',
@@ -233,6 +233,7 @@ export default {
233
233
  canRead: 'Kan lezen',
234
234
  worksIn: 'Werkt in'
235
235
  },
236
+ loading: 'Gesprek laden',
236
237
  loadingEarlier: 'Eerdere berichten laden',
237
238
  stripFailed: 'Kon de berichten niet tonen',
238
239
  cardFailed: 'Kon deze kaart niet tonen',
@@ -1,5 +1,6 @@
1
1
  import i18next from 'i18next';
2
2
  import type { ReactNode } from 'react';
3
+ import type { AgentKind } from '@adecore/agent-contracts';
3
4
  import type { ChatHost } from '../../host';
4
5
  import { isBlockingPrompt, type PendingPrompt, type PromptAction, type PromptDraft } from './prompts';
5
6
 
@@ -20,7 +21,9 @@ export interface HostPrompt {
20
21
  }
21
22
 
22
23
  /* What a prompt card draws, whatever asked it. The chat's items pass through unchanged. */
23
- export type PromptSubject = { kind: 'chat'; nodeId: string; item: PendingPrompt } | { kind: 'host'; nodeId: string; prompt: HostPrompt };
24
+ export type PromptSubject =
25
+ | { kind: 'chat'; nodeId: string; item: PendingPrompt; source?: { label: string; provider?: AgentKind } }
26
+ | { kind: 'host'; nodeId: string; prompt: HostPrompt };
24
27
 
25
28
  /* The requests a chat's own prompts are answered with. */
26
29
  export interface ChatPromptClients {
@@ -81,7 +81,7 @@ export function PromptCard({ kind, heading, meta, top, actions, busy, notice, er
81
81
  {heading}
82
82
  </h3>
83
83
  </div>
84
- {meta && <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted">{meta}</div>}
84
+ {meta && <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">{meta}</div>}
85
85
  {children}
86
86
  {notice && <p className="text-xs text-text-muted">{notice}</p>}
87
87
  {error && (
@@ -1,4 +1,5 @@
1
1
  import { useTranslation } from 'react-i18next';
2
+ import { Pill } from '@adecore/ui';
2
3
  import { toolSummary } from '../../chat/logic/tools';
3
4
  import { useChatHost } from '../../host';
4
5
  import { answerValue, promptAnswers, questionAnswer, type PromptDraft } from '../logic/prompts';
@@ -43,8 +44,13 @@ export function PromptView(props: PromptViewProps) {
43
44
  };
44
45
  const multiple = item.kind === 'question' && item.questions.length > 1;
45
46
  const meta =
46
- more > 0 || hasDraft || multiple ? (
47
+ subject.source !== undefined || more > 0 || hasDraft || multiple ? (
47
48
  <>
49
+ {subject.source && (
50
+ <Pill size="sm" className="max-w-full">
51
+ <span className="truncate">{subject.source.label}</span>
52
+ </Pill>
53
+ )}
48
54
  {more > 0 && <span className="tabular-nums">{t('meta.waiting', { count: more + 1 })}</span>}
49
55
  {multiple && <span className="tabular-nums">{t('meta.question', { index: draft.index + 1, total: item.questions.length })}</span>}
50
56
  {hasDraft && <span>{t('meta.draftSaved')}</span>}
@@ -17,6 +17,8 @@ import { ChatScopeContext, useChatScope } from '../scope';
17
17
 
18
18
  export interface ChatState {
19
19
  info: ChatInfo;
20
+ /* Status and live events do not establish where the initial history begins. */
21
+ historyPending?: boolean;
20
22
  items: Record<string, ChatItem>;
21
23
  /*
22
24
  * The items as the timeline groups them: the same map as `items`, except that a delta growing a
@@ -63,11 +65,18 @@ export type ChatStatuses = Record<string, Pick<ChatState, 'info'>>;
63
65
 
64
66
  /* What a chat client writes. It owns one host's link, so it speaks in chat ids alone. */
65
67
  export interface ChatSink {
68
+ /*
69
+ * A token held by the cached thread, replaced on reset or eviction. It may change after a write.
70
+ * Without it a client can only retain a replay checkpoint while the chat is mounted.
71
+ */
72
+ historyIdentity?(chatId: string): object | undefined;
66
73
  /* Without a page the items are the whole thread. */
67
74
  reset(chatId: string, info: ChatInfo, items: ChatItem[], page?: ChatPage): void;
68
75
  /* The page before the one the chat holds, read with `cursor`. */
69
76
  prepend(chatId: string, cursor: string, page: ChatHistoryResult): void;
70
77
  apply(chatId: string, event: ChatEvent): void;
78
+ /* A reconnect can publish its complete replay at once; absent sinks receive each event through apply. */
79
+ replay?(chatId: string, events: readonly ChatEvent[]): void;
71
80
  /* What a chat is doing, for a thread nobody in this window has open. */
72
81
  status(chatId: string, info: ChatInfo): void;
73
82
  bookmarks(chatId: string, bookmarks: ChatBookmark[]): void;
@@ -82,6 +91,7 @@ interface ChatsStore {
82
91
  reset(key: string, info: ChatInfo, items: ChatItem[], page?: ChatPage): void;
83
92
  prepend(key: string, cursor: string, page: ChatHistoryResult): void;
84
93
  apply(key: string, event: ChatEvent): void;
94
+ replay(key: string, events: readonly ChatEvent[]): void;
85
95
  status(key: string, info: ChatInfo): void;
86
96
  bookmarks(key: string, bookmarks: ChatBookmark[]): void;
87
97
  visuals(key: string, visuals: ChatVisual[]): void;
@@ -121,18 +131,20 @@ function isBeforePage(state: ChatState, historyIndex: number | undefined): boole
121
131
  return state.history !== undefined && historyIndex !== undefined && historyIndex < state.history.start;
122
132
  }
123
133
 
134
+ type ItemWriter = (state: ChatState, item: ChatItem, structural?: boolean) => ChatState;
135
+
124
136
  /* An item from before the page this client holds matters only as a sub-agent, a request that waits, or one that stopped waiting. */
125
- function withEarlierItem(state: ChatState, item: ChatItem): ChatState {
137
+ function withEarlierItem(state: ChatState, item: ChatItem, write: ItemWriter): ChatState {
126
138
  if (item.kind === 'subagent') {
127
139
  const subagents = state.subagentsBefore ?? [];
128
- return { ...withItem(state, item), subagentsBefore: subagents.includes(item.id) ? subagents : [...subagents, item.id] };
140
+ return { ...write(state, item), subagentsBefore: subagents.includes(item.id) ? subagents : [...subagents, item.id] };
129
141
  }
130
142
  const waiting = state.waitingBefore ?? [];
131
143
  const held = waiting.includes(item.id);
132
144
  if (isWaiting(item)) {
133
- return { ...withItem(state, item), waitingBefore: held ? waiting : [...waiting, item.id] };
145
+ return { ...write(state, item), waitingBefore: held ? waiting : [...waiting, item.id] };
134
146
  }
135
- return held ? { ...withItem(state, item), waitingBefore: waiting.filter((id) => id !== item.id) } : state;
147
+ return held ? { ...write(state, item), waitingBefore: waiting.filter((id) => id !== item.id) } : state;
136
148
  }
137
149
 
138
150
  /* The page before the one a chat holds, unless the thread was read again since that page was asked for. */
@@ -140,6 +152,7 @@ export function prependPage(state: ChatState, cursor: string, page: ChatHistoryR
140
152
  if (state.history?.cursor !== cursor) {
141
153
  return state;
142
154
  }
155
+ const { historyPending: _historyPending, ...loaded } = state;
143
156
  const held = new Set(state.order);
144
157
  const older = page.items.filter((item) => !held.has(item.id));
145
158
  const olderIds = new Set(older.map((item) => item.id));
@@ -150,12 +163,12 @@ export function prependPage(state: ChatState, cursor: string, page: ChatHistoryR
150
163
  }
151
164
  const order = [...olderIds, ...state.order];
152
165
  if (page.history.cursor === null) {
153
- const { history: _history, waitingBefore: _waitingBefore, subagentsBefore: _subagentsBefore, ...rest } = state;
166
+ const { history: _history, waitingBefore: _waitingBefore, subagentsBefore: _subagentsBefore, ...rest } = loaded;
154
167
  return { ...rest, items, structure: items, order };
155
168
  }
156
169
  const waitingBefore = (state.waitingBefore ?? []).filter((id) => !olderIds.has(id));
157
170
  const subagentsBefore = (state.subagentsBefore ?? []).filter((id) => !olderIds.has(id));
158
- return { ...state, items, structure: items, order, history: page.history, waitingBefore, subagentsBefore };
171
+ return { ...loaded, items, structure: items, order, history: page.history, waitingBefore, subagentsBefore };
159
172
  }
160
173
 
161
174
  /* The requests that still wait, oldest first: those from before the page this client holds, then the thread's own. */
@@ -163,10 +176,9 @@ export function waitingRequestsOf(chat: Pick<ChatState, 'structure' | 'order' |
163
176
  return [...(chat?.waitingBefore ?? []), ...(chat?.order ?? [])].map((id) => chat?.structure[id]).filter(isWaiting);
164
177
  }
165
178
 
166
- /* The state with one item replaced, in the thread and in the structure the rows come from. */
167
- function withItem(state: ChatState, item: ChatItem): ChatState {
179
+ function withItem(state: ChatState, item: ChatItem, structural = true): ChatState {
168
180
  const items = { ...state.items, [item.id]: item };
169
- return { ...state, items, structure: items };
181
+ return structural ? { ...state, items, structure: items } : { ...state, items };
170
182
  }
171
183
 
172
184
  /* Cheap enough, since a status only arrives when the host saw one change, never on a streamed word. */
@@ -184,26 +196,47 @@ function without<T>(rows: Record<string, T>, matches: (key: string) => boolean):
184
196
  }
185
197
 
186
198
  export function applyEvent(state: ChatState, event: ChatEvent): ChatState {
199
+ return applyUpdate(state, event, withItem);
200
+ }
201
+
202
+ function replayEvents(state: ChatState, events: readonly ChatEvent[]): ChatState {
203
+ let draft: Record<string, ChatItem> | undefined;
204
+ const write: ItemWriter = (current, item, structural = true) => {
205
+ // A text delta must leave the last structural snapshot intact, including within this replay.
206
+ if (draft !== current.items || (!structural && draft === current.structure)) {
207
+ draft = { ...current.items };
208
+ }
209
+ // Item ids may be __proto__, so this must define an own data property.
210
+ Object.defineProperty(draft, item.id, { value: item, enumerable: true, configurable: true, writable: true });
211
+ return structural ? { ...current, items: draft, structure: draft } : { ...current, items: draft };
212
+ };
213
+ for (const event of events) {
214
+ state = applyUpdate(state, event, write);
215
+ }
216
+ return state;
217
+ }
218
+
219
+ function applyUpdate(state: ChatState, event: ChatEvent, write: ItemWriter): ChatState {
187
220
  switch (event.type) {
188
221
  case 'item': {
189
222
  if (isBeforePage(state, event.historyIndex)) {
190
- return withEarlierItem(state, event.item);
223
+ return withEarlierItem(state, event.item, write);
191
224
  }
192
225
  const known = event.item.id in state.items;
193
- return { ...withItem(state, event.item), order: known ? state.order : [...state.order, event.item.id] };
226
+ return { ...write(state, event.item), order: known ? state.order : [...state.order, event.item.id] };
194
227
  }
195
228
  case 'delta': {
196
229
  const item = state.items[event.itemId];
197
230
  if (event.uiQueries && item?.kind === 'assistant') {
198
231
  // The text first, as the daemon's thread applies it, then the metadata on what it grew into.
199
232
  const { uiQueries, ...text } = event;
200
- const grown = event.text === '' ? state : applyEvent(state, text);
233
+ const grown = event.text === '' ? state : applyUpdate(state, text, write);
201
234
  const reply = grown.items[event.itemId] ?? item;
202
- return { ...grown, items: { ...grown.items, [event.itemId]: { ...reply, uiQueries } as ChatItem } };
235
+ return write(grown, { ...reply, uiQueries } as ChatItem, false);
203
236
  }
204
237
  if (event.ui !== undefined && event.text === '') {
205
238
  if (item?.kind === 'assistant' && item.streaming && event.textLength === item.text.length) {
206
- return { ...state, items: { ...state.items, [event.itemId]: { ...item, ui: event.ui } } };
239
+ return write(state, { ...item, ui: event.ui }, false);
207
240
  }
208
241
  return state;
209
242
  }
@@ -211,14 +244,14 @@ export function applyEvent(state: ChatState, event: ChatEvent): ChatState {
211
244
  const grown = { ...item, text: item.text + event.text };
212
245
  // The first text is structure after all, since an empty reply that is not streaming has no row.
213
246
  if (item.text === '') {
214
- return withItem(state, grown);
247
+ return write(state, grown);
215
248
  }
216
- return { ...state, items: { ...state.items, [event.itemId]: grown } };
249
+ return write(state, grown, false);
217
250
  }
218
251
  if (item?.kind === 'tool' && item.state === 'running') {
219
252
  const progress = item.progress ?? { startedAt: null, description: null, output: null };
220
253
  const grown = { ...item, progress: { ...progress, output: appendToolOutput(progress.output, event.text) } };
221
- return { ...state, items: { ...state.items, [event.itemId]: grown } };
254
+ return write(state, grown, false);
222
255
  }
223
256
  return state;
224
257
  }
@@ -272,6 +305,16 @@ export const useChats = create<ChatsStore>((set) => ({
272
305
  return { byKey: { ...s.byKey, [key]: next }, statusByKey: withStatus(s.statusByKey, key, next.info) };
273
306
  });
274
307
  },
308
+ replay(key, events) {
309
+ set((store) => {
310
+ const current = store.byKey[key];
311
+ if (!current || events.length === 0) {
312
+ return store;
313
+ }
314
+ const next = replayEvents(current, events);
315
+ return next === current ? store : { byKey: { ...store.byKey, [key]: next }, statusByKey: withStatus(store.statusByKey, key, next.info) };
316
+ });
317
+ },
275
318
  /*
276
319
  * The status of a chat this window is not reading, which the host sends for every chat it has
277
320
  * loaded. The thread stays empty until somebody attaches, since this is what a header, a list and
@@ -281,7 +324,7 @@ export const useChats = create<ChatsStore>((set) => ({
281
324
  set((s) => {
282
325
  const current = s.byKey[key];
283
326
  if (!current) {
284
- return { byKey: { ...s.byKey, [key]: stateOf(info, []) }, statusByKey: withStatus(s.statusByKey, key, info) };
327
+ return { byKey: { ...s.byKey, [key]: { ...stateOf(info, []), historyPending: true } }, statusByKey: withStatus(s.statusByKey, key, info) };
285
328
  }
286
329
  // An attached client already had this on `chat.event`; writing it again would redraw the thread.
287
330
  if (sameInfo(current.info, info)) {
@@ -313,9 +356,11 @@ export const useChats = create<ChatsStore>((set) => ({
313
356
  /* The sink of one host's chat client: it hands over chat ids, this puts them under the keys of its scope. */
314
357
  export function chatSink(keyOf: (chatId: string) => string): ChatSink {
315
358
  return {
359
+ historyIdentity: (chatId) => useChats.getState().byKey[keyOf(chatId)]?.order,
316
360
  reset: (chatId, info, items, page) => useChats.getState().reset(keyOf(chatId), info, items, page),
317
361
  prepend: (chatId, cursor, page) => useChats.getState().prepend(keyOf(chatId), cursor, page),
318
362
  apply: (chatId, event) => useChats.getState().apply(keyOf(chatId), event),
363
+ replay: (chatId, events) => useChats.getState().replay(keyOf(chatId), events),
319
364
  status: (chatId, info) => useChats.getState().status(keyOf(chatId), info),
320
365
  bookmarks: (chatId, bookmarks) => useChats.getState().bookmarks(keyOf(chatId), bookmarks),
321
366
  visuals: (chatId, visuals) => useChats.getState().visuals(keyOf(chatId), visuals),
package/src/theme.css CHANGED
@@ -110,7 +110,7 @@
110
110
  */
111
111
  .chat-column .chat-column-content { margin-inline: auto; max-width: var(--chat-measure, 768px); }
112
112
  /* Wide pages clear the scrubber even when the reply column needs no extra padding. */
113
- .chat-wide-content { max-width: calc(100% - 2 * var(--chat-wide-inset, 0px)); }
113
+ .chat-wide-content { max-width: min(calc(100% - 2 * var(--chat-wide-inset, 0px)), var(--chat-wide-measure, 100%)); }
114
114
  /* Wider by the input's inset (`.composer-input .cm-content` padding plus the card's border), so what is typed lines up with the thread. */
115
115
  .chat-column .chat-composer-content { margin-inline: auto; max-width: calc(var(--chat-measure, 768px) + 2 * 21px); }
116
116