@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.
- package/dist/chat/chat-client.d.ts +5 -0
- package/dist/chat/chat-client.js +91 -20
- package/dist/chat/index.d.ts +1 -0
- package/dist/chat/index.js +2 -1
- package/dist/chat/logic/picker-entries.js +11 -6
- package/dist/chat/logic/scrubber.d.ts +4 -2
- package/dist/chat/logic/scrubber.js +3 -3
- package/dist/chat/pending-prompts.d.ts +15 -0
- package/dist/chat/pending-prompts.js +93 -0
- package/dist/chat/reading-width.js +6 -2
- package/dist/chat/ui/Pickers.js +6 -3
- package/dist/chat/ui/PromptComposer.d.ts +2 -2
- package/dist/chat/ui/PromptComposer.js +92 -98
- package/dist/chat/ui/RunSettings.js +2 -2
- package/dist/chat/ui/SubagentTimeline.js +128 -182
- package/dist/chat/ui/Timeline.js +86 -60
- package/dist/chat/ui/code-lines.d.ts +5 -0
- package/dist/chat/ui/code-lines.js +37 -0
- package/dist/chat/ui/composer/ComposerControls.js +1 -1
- package/dist/chat/ui/composer/use-pending-requests.d.ts +2 -2
- package/dist/chat/ui/composer/use-pending-requests.js +29 -37
- package/dist/chat/ui/highlighter.d.ts +1 -1
- package/dist/chat/ui/highlighter.js +4 -2
- package/dist/chat/ui/rows/MessageRows.js +1 -1
- package/dist/locales/en/agent-chat.d.ts +1 -0
- package/dist/locales/en/agent-chat.js +1 -0
- package/dist/locales/nl/agent-chat.d.ts +1 -0
- package/dist/locales/nl/agent-chat.js +1 -0
- package/dist/prompts/logic/subjects.d.ts +5 -0
- package/dist/prompts/ui/PromptCard.js +1 -1
- package/dist/prompts/ui/PromptView.js +104 -95
- package/dist/state/chats.d.ts +4 -0
- package/dist/state/chats.js +84 -48
- package/dist/theme.css +1 -1
- package/package.json +8 -8
- package/src/chat/chat-client.ts +92 -21
- package/src/chat/index.ts +2 -0
- package/src/chat/logic/picker-entries.ts +6 -4
- package/src/chat/logic/scrubber.ts +8 -6
- package/src/chat/pending-prompts.ts +108 -0
- package/src/chat/reading-width.ts +9 -2
- package/src/chat/ui/Pickers.tsx +3 -6
- package/src/chat/ui/PromptComposer.tsx +10 -7
- package/src/chat/ui/RunSettings.tsx +2 -2
- package/src/chat/ui/SubagentTimeline.tsx +1 -24
- package/src/chat/ui/Timeline.tsx +66 -40
- package/src/chat/ui/code-lines.ts +37 -0
- package/src/chat/ui/composer/ComposerControls.tsx +1 -1
- package/src/chat/ui/composer/use-pending-requests.ts +13 -21
- package/src/chat/ui/highlighter.ts +4 -3
- package/src/chat/ui/rows/MessageRows.tsx +1 -1
- package/src/locales/en/agent-chat.ts +1 -0
- package/src/locales/nl/agent-chat.ts +1 -0
- package/src/prompts/logic/subjects.ts +4 -1
- package/src/prompts/ui/PromptCard.tsx +1 -1
- package/src/prompts/ui/PromptView.tsx +7 -1
- package/src/state/chats.ts +63 -18
- package/src/theme.css +1 -1
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
{
|
|
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:
|
|
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
|
-
{
|
|
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 {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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',
|
|
@@ -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 =
|
|
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>}
|
package/src/state/chats.ts
CHANGED
|
@@ -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 { ...
|
|
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 { ...
|
|
145
|
+
return { ...write(state, item), waitingBefore: held ? waiting : [...waiting, item.id] };
|
|
134
146
|
}
|
|
135
|
-
return held ? { ...
|
|
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 } =
|
|
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 { ...
|
|
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
|
-
|
|
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 { ...
|
|
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 :
|
|
233
|
+
const grown = event.text === '' ? state : applyUpdate(state, text, write);
|
|
201
234
|
const reply = grown.items[event.itemId] ?? item;
|
|
202
|
-
return
|
|
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
|
|
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
|
|
247
|
+
return write(state, grown);
|
|
215
248
|
}
|
|
216
|
-
return
|
|
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
|
|
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
|
|