@adecore/agents-react 0.20.0 → 0.21.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 +1 -0
- package/dist/chat/logic/generated-image.d.ts +27 -0
- package/dist/chat/logic/generated-image.js +32 -0
- package/dist/chat/logic/timeline-target.d.ts +19 -1
- package/dist/chat/logic/timeline-target.js +49 -3
- package/dist/chat/logic/timeline.js +25 -17
- package/dist/chat/subagent-conversation.d.ts +2 -1
- package/dist/chat/subagent-conversation.js +7 -1
- package/dist/chat/timeline-flash.d.ts +3 -1
- package/dist/chat/timeline-flash.js +2 -2
- package/dist/chat/ui/CodeBlock.d.ts +3 -1
- package/dist/chat/ui/CodeBlock.js +38 -7
- package/dist/chat/ui/Composer.js +13 -3
- package/dist/chat/ui/ImageView.d.ts +8 -2
- package/dist/chat/ui/ImageView.js +12 -6
- package/dist/chat/ui/Markdown.d.ts +7 -1
- package/dist/chat/ui/Markdown.js +80 -23
- package/dist/chat/ui/MessageActions.js +6 -7
- package/dist/chat/ui/SubagentTimeline.js +7 -3
- package/dist/chat/ui/Timeline.js +68 -8
- package/dist/chat/ui/TimelineMenu.js +7 -3
- package/dist/chat/ui/file-links.d.ts +2 -1
- package/dist/chat/ui/file-links.js +8 -3
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
- package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
- package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
- package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
- package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
- package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
- package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
- package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/registry.js +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
- package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
- package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
- package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/tones.js +31 -0
- package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
- package/dist/chat/ui/reply-context.d.ts +8 -0
- package/dist/chat/ui/reply-context.js +1 -0
- package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
- package/dist/chat/ui/rows/MessageRows.js +27 -7
- package/dist/chat/ui/rows/Rows.js +4 -2
- package/dist/chat/ui/rows/SubagentRow.js +5 -0
- package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -3
- package/dist/host.d.ts +25 -3
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +89 -1
- package/dist/locales/nl/agent-chat.json +89 -1
- package/dist/state/chats.js +9 -0
- package/dist/theme.css +38 -2
- package/package.json +133 -7
- package/src/chat/chat-client.ts +1 -0
- package/src/chat/logic/generated-image.ts +48 -0
- package/src/chat/logic/timeline-target.ts +69 -4
- package/src/chat/logic/timeline.ts +28 -17
- package/src/chat/subagent-conversation.ts +13 -2
- package/src/chat/timeline-flash.ts +5 -3
- package/src/chat/ui/CodeBlock.tsx +70 -21
- package/src/chat/ui/Composer.tsx +22 -5
- package/src/chat/ui/ImageView.tsx +51 -8
- package/src/chat/ui/Markdown.tsx +123 -26
- package/src/chat/ui/MessageActions.tsx +15 -8
- package/src/chat/ui/SubagentTimeline.tsx +26 -1
- package/src/chat/ui/Timeline.tsx +98 -22
- package/src/chat/ui/TimelineMenu.tsx +6 -3
- package/src/chat/ui/file-links.ts +9 -3
- package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
- package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
- package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
- package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
- package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
- package/src/chat/ui/intelligent-ui/registry.ts +52 -0
- package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
- package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
- package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
- package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
- package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
- package/src/chat/ui/intelligent-ui/tones.ts +37 -0
- package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
- package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
- package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
- package/src/chat/ui/reply-context.ts +7 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
- package/src/chat/ui/rows/MessageRows.tsx +69 -7
- package/src/chat/ui/rows/Rows.tsx +4 -2
- package/src/chat/ui/rows/SubagentRow.tsx +5 -0
- package/src/chat/ui/rows/WorkRows.tsx +20 -5
- package/src/host.ts +46 -3
- package/src/locales/en/agent-chat.json +89 -1
- package/src/locales/nl/agent-chat.json +89 -1
- package/src/state/chats.ts +9 -0
- package/src/theme.css +38 -2
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -32,8 +32,17 @@ import {
|
|
|
32
32
|
ticksOf,
|
|
33
33
|
ticksWithHits
|
|
34
34
|
} from '../logic/scrubber';
|
|
35
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) {
|
|
@@ -498,7 +572,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
498
572
|
// A file an answer names is relative to the folder this chat runs in, which is a worktree as
|
|
499
573
|
// often as it is the project itself.
|
|
500
574
|
<FileLinkContext.Provider value={info?.cwd ?? null}>
|
|
501
|
-
<div ref={frameRef} className="relative flex min-h-0 grow flex-col">
|
|
575
|
+
<div ref={frameRef} className="relative flex min-h-0 grow flex-col" data-file-cwd={info?.cwd ?? ''} data-file-scope-id={scope.id}>
|
|
502
576
|
{searching && chatFind.bar}
|
|
503
577
|
<ContextMenu.Root>
|
|
504
578
|
<div
|
|
@@ -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
|
-
<
|
|
578
|
-
<
|
|
579
|
-
<
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
})}
|
|
@@ -8,7 +8,7 @@ import type { TimelineTarget } from '../logic/timeline-target';
|
|
|
8
8
|
import { chatHost } from '../../host';
|
|
9
9
|
import { useChatScope } from '../../scope';
|
|
10
10
|
import { useChatRow } from '../../state/chats';
|
|
11
|
-
import { openFileLink, useFileLinkCwd } from './file-links';
|
|
11
|
+
import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links';
|
|
12
12
|
import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
|
|
13
13
|
|
|
14
14
|
/*
|
|
@@ -34,10 +34,13 @@ export function TimelineMenuPopup({
|
|
|
34
34
|
const markable = chatId !== null && (target.row?.kind === 'user' || target.row?.kind === 'assistant') ? target.row.id : null;
|
|
35
35
|
// A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
|
|
36
36
|
const cwd = useFileLinkCwd();
|
|
37
|
+
const scopeId = useFileLinkScopeId();
|
|
37
38
|
const { fork, fileLinks } = chatHost();
|
|
38
39
|
const openInPreview = (): void => {
|
|
39
|
-
if (target.
|
|
40
|
-
openFileLink(cwd,
|
|
40
|
+
if (target.file != null) {
|
|
41
|
+
openFileLink(target.file.cwd, target.file.ref, target.file.scopeId);
|
|
42
|
+
} else if (target.path !== null) {
|
|
43
|
+
openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false }, scopeId);
|
|
41
44
|
}
|
|
42
45
|
};
|
|
43
46
|
return (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo } from 'react';
|
|
2
2
|
import { chatHost, type FileRef } from '../../host';
|
|
3
|
+
import { ChatScopeContext } from '../../scope';
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
* The folder a relative reference in rendered text counts from: a chat's own cwd, which is a
|
|
@@ -12,13 +13,18 @@ export function useFileLinkCwd(): string | null {
|
|
|
12
13
|
return useContext(FileLinkContext);
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export function useFileLinkScopeId(): string | null {
|
|
17
|
+
return useContext(ChatScopeContext)?.id ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
/* The reference a piece of text names, only where this spot on screen could open it. */
|
|
16
21
|
export function useFileLinkTarget(text: string): FileRef | null {
|
|
17
22
|
const cwd = useFileLinkCwd();
|
|
18
|
-
|
|
23
|
+
const scopeId = useFileLinkScopeId();
|
|
24
|
+
return useMemo(() => chatHost().fileLinks?.target(text, cwd, scopeId) ?? null, [text, cwd, scopeId]);
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/* A reference followed, the way the app opens a file or a folder. */
|
|
22
|
-
export function openFileLink(cwd: string | null, ref: FileRef): void {
|
|
23
|
-
chatHost().fileLinks?.open(cwd, ref);
|
|
28
|
+
export function openFileLink(cwd: string | null, ref: FileRef, scopeId: string | null = null): void {
|
|
29
|
+
chatHost().fileLinks?.open(cwd, ref, scopeId);
|
|
24
30
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useId, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
|
|
4
|
+
import { Icon, Spinner, Tooltip, useTickingText } from '@adecore/ui';
|
|
5
|
+
import { formatAgo, formatMoment } from '@adecore/ui/format';
|
|
6
|
+
import type { UiLiveStatus } from './render-context';
|
|
7
|
+
import { useLater } from './use-later';
|
|
8
|
+
|
|
9
|
+
/* Something the compiler repaired, such as a prop it dropped; never an error to a person. */
|
|
10
|
+
export interface UiBlockFix {
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UiBlockFooterProps {
|
|
16
|
+
live?: UiLiveStatus | null;
|
|
17
|
+
/* The label of the Choice that answered the block, and when, in milliseconds since the epoch. */
|
|
18
|
+
answered?: { label: string; at: number } | null;
|
|
19
|
+
fixes?: readonly UiBlockFix[];
|
|
20
|
+
/* Why the whole block is drawn as its text, such as that it is too large. */
|
|
21
|
+
shownAsText?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NO_FIXES: readonly UiBlockFix[] = [];
|
|
25
|
+
const READING_SPINNER_MS = 1000;
|
|
26
|
+
|
|
27
|
+
/* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
|
|
28
|
+
function LivePart({ live }: { live: UiLiveStatus }) {
|
|
29
|
+
const { t } = useTranslation('agent-chat');
|
|
30
|
+
const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
|
|
31
|
+
const { readAt } = live;
|
|
32
|
+
const fresh = live.state === 'fresh' || live.state === 'reading';
|
|
33
|
+
const ago = useTickingText(() => {
|
|
34
|
+
if (readAt === null) {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
const time = formatAgo(Date.now() - readAt);
|
|
38
|
+
return fresh ? t('blocks.live.read', { time }) : time;
|
|
39
|
+
});
|
|
40
|
+
const source = live.source ?? live.sources[0] ?? '';
|
|
41
|
+
const icon = slow ? (
|
|
42
|
+
<Spinner size={12} />
|
|
43
|
+
) : live.state === 'failed' ? (
|
|
44
|
+
<Icon icon={TriangleAlert} size={12} className="text-status-needs-you" />
|
|
45
|
+
) : live.state === 'refused' ? (
|
|
46
|
+
<Icon icon={Lock} size={12} />
|
|
47
|
+
) : (
|
|
48
|
+
<Icon icon={Radio} size={12} className="text-status-idle" />
|
|
49
|
+
);
|
|
50
|
+
const words =
|
|
51
|
+
live.state === 'failed'
|
|
52
|
+
? t('blocks.live.failed', { source })
|
|
53
|
+
: live.state === 'refused'
|
|
54
|
+
? t('blocks.live.refused', { source })
|
|
55
|
+
: t('blocks.live.label');
|
|
56
|
+
const hint =
|
|
57
|
+
live.state === 'failed' && live.reason !== undefined
|
|
58
|
+
? live.reason
|
|
59
|
+
: live.sources.length > 0
|
|
60
|
+
? t('blocks.live.sources', { sources: live.sources.join(', ') })
|
|
61
|
+
: null;
|
|
62
|
+
const body = (
|
|
63
|
+
<span tabIndex={hint === null ? undefined : 0} className="flex items-center gap-1.5">
|
|
64
|
+
{icon}
|
|
65
|
+
<span>{words}</span>
|
|
66
|
+
{readAt !== null && live.state !== 'refused' && (
|
|
67
|
+
<>
|
|
68
|
+
<span aria-hidden>·</span>
|
|
69
|
+
<time dateTime={new Date(readAt).toISOString()}>
|
|
70
|
+
<span ref={ago} className="tabular-nums" />
|
|
71
|
+
</time>
|
|
72
|
+
</>
|
|
73
|
+
)}
|
|
74
|
+
{readAt === null && fresh && (
|
|
75
|
+
<>
|
|
76
|
+
<span aria-hidden>·</span>
|
|
77
|
+
<span>{t('blocks.live.notRead')}</span>
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
80
|
+
</span>
|
|
81
|
+
);
|
|
82
|
+
return hint === null ? body : <Tooltip label={hint}>{body}</Tooltip>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* The line under a block, only when it has something to say: live, answered, repaired or drawn as
|
|
87
|
+
* text, in that order. A hairline of the soft border runs the full width of the card over it.
|
|
88
|
+
*/
|
|
89
|
+
export function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
|
|
90
|
+
const { t } = useTranslation('agent-chat');
|
|
91
|
+
const [fixesOpen, setFixesOpen] = useState(false);
|
|
92
|
+
const listId = useId();
|
|
93
|
+
if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return (
|
|
97
|
+
<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">
|
|
98
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
99
|
+
{live !== null && <LivePart live={live} />}
|
|
100
|
+
{answered !== null && (
|
|
101
|
+
<span className="flex items-center gap-1.5">
|
|
102
|
+
<span>{t('blocks.answered', { label: answered.label })}</span>
|
|
103
|
+
<span aria-hidden>·</span>
|
|
104
|
+
<time dateTime={new Date(answered.at).toISOString()}>{formatMoment(answered.at)}</time>
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
{fixes.length > 0 && (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
aria-expanded={fixesOpen}
|
|
111
|
+
aria-controls={listId}
|
|
112
|
+
className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
|
|
113
|
+
onClick={() => setFixesOpen(!fixesOpen)}
|
|
114
|
+
>
|
|
115
|
+
<Icon icon={Wrench} size={12} />
|
|
116
|
+
{t('blocks.fixes', { count: fixes.length })}
|
|
117
|
+
</button>
|
|
118
|
+
)}
|
|
119
|
+
{shownAsText !== null && (
|
|
120
|
+
<span className="flex items-center gap-1.5">
|
|
121
|
+
<span>{t('blocks.shownAsText')}</span>
|
|
122
|
+
<span aria-hidden>·</span>
|
|
123
|
+
<span>{shownAsText}</span>
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
{fixes.length > 0 && (
|
|
128
|
+
<ul id={listId} hidden={!fixesOpen} className="flex flex-col gap-0.5 text-text-muted">
|
|
129
|
+
{fixes.map((fix, index) => (
|
|
130
|
+
<li key={index} className="flex gap-2">
|
|
131
|
+
<span className="shrink-0 font-mono">{fix.code}</span>
|
|
132
|
+
<span className="min-w-0">{fix.message}</span>
|
|
133
|
+
</li>
|
|
134
|
+
))}
|
|
135
|
+
</ul>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { 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 { Skeleton } from '@adecore/ui';
|
|
6
|
+
import { uiBlockHead } from './node-text';
|
|
7
|
+
import { UiHeadContext } from './render-context';
|
|
8
|
+
import { UiBlockFooter, type UiBlockFooterProps } from './UiBlockFooter';
|
|
9
|
+
|
|
10
|
+
export interface UiBlockFrameProps extends UiBlockFooterProps {
|
|
11
|
+
/* The evaluated top-level nodes, read for the Summary that heads the block. */
|
|
12
|
+
nodes: readonly UiViewNode[];
|
|
13
|
+
phase: 'streaming' | 'final';
|
|
14
|
+
/* The rendered nodes, or the markdown of the whole block when it is shown as text. */
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RELEASE_MS = 200;
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The tallest the card has been, while `active`: a streaming block grows and never shrinks until its
|
|
23
|
+
* last compile. Then the card eases down to its own height, or drops there for a person who asked
|
|
24
|
+
* for less motion.
|
|
25
|
+
*/
|
|
26
|
+
function useRisingFloor(active: boolean): [RefObject<HTMLDivElement | null>, number] {
|
|
27
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
28
|
+
const held = useRef(0);
|
|
29
|
+
const [floor, setFloor] = useState(0);
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const element = ref.current;
|
|
32
|
+
if (element === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (active) {
|
|
36
|
+
const observer = new ResizeObserver(() => {
|
|
37
|
+
const height = Math.ceil(element.getBoundingClientRect().height);
|
|
38
|
+
held.current = Math.max(held.current, height);
|
|
39
|
+
setFloor(held.current);
|
|
40
|
+
});
|
|
41
|
+
observer.observe(element);
|
|
42
|
+
return () => {
|
|
43
|
+
observer.disconnect();
|
|
44
|
+
setFloor(0);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// The final render already let go of the floor, so the card measures its own height here.
|
|
48
|
+
const from = held.current;
|
|
49
|
+
const to = element.getBoundingClientRect().height;
|
|
50
|
+
held.current = 0;
|
|
51
|
+
const reduced = element.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
|
|
52
|
+
if (from - to < 1 || reduced) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const release = element.animate([{ minHeight: `${from}px` }, { minHeight: `${to}px` }], { duration: RELEASE_MS, easing: 'ease-out' });
|
|
56
|
+
return () => release.cancel();
|
|
57
|
+
}, [active]);
|
|
58
|
+
return [ref, floor];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* The card of one block, with its footer. Named by its Summary and busy while it streams, but never
|
|
63
|
+
* live: reading out every compile would be noise. A node fades in as it arrives, without a stagger,
|
|
64
|
+
* and for a person who asked for less motion it only fades; a node inside another only fades
|
|
65
|
+
* (`chat-ui-nodes`). Until the first node a skeleton holds the place of the head.
|
|
66
|
+
*/
|
|
67
|
+
export function UiBlockFrame({ nodes, phase, children, className, ...footer }: UiBlockFrameProps) {
|
|
68
|
+
const { t } = useTranslation('agent-chat');
|
|
69
|
+
const streaming = phase === 'streaming';
|
|
70
|
+
const head = uiBlockHead(nodes);
|
|
71
|
+
const [ref, floor] = useRisingFloor(streaming);
|
|
72
|
+
return (
|
|
73
|
+
<UiHeadContext value={head?.id ?? null}>
|
|
74
|
+
<div
|
|
75
|
+
ref={ref}
|
|
76
|
+
role="group"
|
|
77
|
+
aria-label={head === null || head.label === '' ? t('blocks.label') : head.label}
|
|
78
|
+
aria-busy={streaming || undefined}
|
|
79
|
+
className={clsx('flex min-h-11 flex-col rounded-2xl border border-border bg-surface-raised p-2.25', className)}
|
|
80
|
+
style={streaming && floor > 0 ? { minHeight: floor } : undefined}
|
|
81
|
+
>
|
|
82
|
+
<div className="flex flex-col gap-3 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5">
|
|
83
|
+
{nodes.length === 0 && streaming ? <Skeleton className="mx-2 my-1 w-2/5" /> : children}
|
|
84
|
+
</div>
|
|
85
|
+
<UiBlockFooter {...footer} />
|
|
86
|
+
</div>
|
|
87
|
+
</UiHeadContext>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|