@adecore/agents-react 0.20.1 → 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/logic/generated-image.d.ts +27 -0
- package/dist/chat/logic/generated-image.js +32 -0
- package/dist/chat/logic/timeline-target.d.ts +13 -1
- package/dist/chat/logic/timeline-target.js +26 -0
- package/dist/chat/logic/timeline.js +16 -10
- 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/ImageView.d.ts +8 -2
- package/dist/chat/ui/ImageView.js +12 -6
- package/dist/chat/ui/SubagentTimeline.js +6 -2
- package/dist/chat/ui/Timeline.js +67 -7
- 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 +21 -4
- 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 +11 -1
- package/dist/locales/en/agent-chat.json +82 -0
- package/dist/locales/nl/agent-chat.json +82 -0
- package/dist/state/chats.js +9 -0
- package/dist/theme.css +19 -0
- package/package.json +133 -7
- package/src/chat/logic/generated-image.ts +48 -0
- package/src/chat/logic/timeline-target.ts +41 -1
- package/src/chat/logic/timeline.ts +19 -10
- package/src/chat/subagent-conversation.ts +13 -2
- package/src/chat/timeline-flash.ts +5 -3
- package/src/chat/ui/ImageView.tsx +51 -8
- package/src/chat/ui/SubagentTimeline.tsx +24 -1
- package/src/chat/ui/Timeline.tsx +97 -21
- 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 +60 -4
- 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 +27 -1
- package/src/locales/en/agent-chat.json +82 -0
- package/src/locales/nl/agent-chat.json +82 -0
- package/src/state/chats.ts +9 -0
- package/src/theme.css +19 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import i18next from 'i18next';
|
|
2
|
-
import type { ChatItem, ChatSubagentResult } from '@adecore/agent-contracts';
|
|
2
|
+
import type { ChatApprovalItem, ChatItem, ChatQuestionItem, ChatSubagentResult } from '@adecore/agent-contracts';
|
|
3
3
|
import { errorCode, isConnectionError, type ChatTransport } from '../transport';
|
|
4
4
|
|
|
5
5
|
// What the panel opens on: the newest end of the conversation, which is where an agent that works is.
|
|
@@ -8,6 +8,7 @@ export const SUBAGENT_PAGE = 60;
|
|
|
8
8
|
export interface SubagentConversationState {
|
|
9
9
|
status: 'loading' | 'ready' | 'failed';
|
|
10
10
|
items: ChatItem[];
|
|
11
|
+
pending: Array<ChatApprovalItem | ChatQuestionItem>;
|
|
11
12
|
source: ChatSubagentResult['source'] | null;
|
|
12
13
|
context: NonNullable<ChatSubagentResult['context']> | null;
|
|
13
14
|
/* Where the page before the oldest item on screen starts; null once the start is there. */
|
|
@@ -23,6 +24,7 @@ export interface SubagentConversationState {
|
|
|
23
24
|
export const INITIAL_CONVERSATION: SubagentConversationState = {
|
|
24
25
|
status: 'loading',
|
|
25
26
|
items: [],
|
|
27
|
+
pending: [],
|
|
26
28
|
source: null,
|
|
27
29
|
context: null,
|
|
28
30
|
cursor: null,
|
|
@@ -59,6 +61,13 @@ export function mergeNewest(
|
|
|
59
61
|
return { items, cursor: current.length === 0 ? pageCursor : currentCursor };
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
function pendingOf(items: readonly ChatItem[]): Array<ChatApprovalItem | ChatQuestionItem> {
|
|
65
|
+
return items.filter(
|
|
66
|
+
(item): item is ChatApprovalItem | ChatQuestionItem =>
|
|
67
|
+
(item.kind === 'approval' && item.decision === 'pending') || (item.kind === 'question' && item.state === 'pending')
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
function messageOf(error: unknown): string {
|
|
63
72
|
return error instanceof Error ? error.message : i18next.t('agent-chat:subagents.noAnswer');
|
|
64
73
|
}
|
|
@@ -131,7 +140,8 @@ export class SubagentConversation {
|
|
|
131
140
|
this.set({ loadingEarlier: true });
|
|
132
141
|
try {
|
|
133
142
|
const page = await this.ask({ cursor, limit: SUBAGENT_PAGE });
|
|
134
|
-
|
|
143
|
+
const items = [...page.items, ...this.state.items];
|
|
144
|
+
this.set({ items, pending: page.pending ?? pendingOf(items), cursor: page.history.cursor, live: page.live, loadingEarlier: false });
|
|
135
145
|
} catch (error) {
|
|
136
146
|
if (errorCode(error) === 'history-expired') {
|
|
137
147
|
// The record was written again under this panel; what it holds now is the only truth.
|
|
@@ -167,6 +177,7 @@ export class SubagentConversation {
|
|
|
167
177
|
this.set({
|
|
168
178
|
status: 'ready',
|
|
169
179
|
items: merged.items,
|
|
180
|
+
pending: page.pending ?? pendingOf(merged.items),
|
|
170
181
|
cursor: merged.cursor,
|
|
171
182
|
live: page.live,
|
|
172
183
|
source: page.source,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
|
+
import type { UiReplyTarget } from './logic/timeline-target';
|
|
2
3
|
|
|
3
4
|
/* The row of a thread a jump landed on: it lights up once, and a card of changes opens when the jump asked for it. */
|
|
4
5
|
export interface TimelineFlash {
|
|
@@ -6,13 +7,14 @@ export interface TimelineFlash {
|
|
|
6
7
|
key: string;
|
|
7
8
|
rowId: string;
|
|
8
9
|
opens: boolean;
|
|
10
|
+
block?: Pick<UiReplyTarget, 'blockId' | 'revision'>;
|
|
9
11
|
/* A second jump to the same row counts. */
|
|
10
12
|
nonce: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
interface TimelineFlashStore {
|
|
14
16
|
target: TimelineFlash | null;
|
|
15
|
-
flash(key: string, rowId: string, opens: boolean): void;
|
|
17
|
+
flash(key: string, rowId: string, opens: boolean, block?: TimelineFlash['block']): void;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/* A little longer than the animation of `.chat-flash`, so a row that scrolls into view again later is neither lit nor opened a second time. */
|
|
@@ -20,9 +22,9 @@ const FLASH_MS = 2000;
|
|
|
20
22
|
|
|
21
23
|
export const useTimelineFlash = create<TimelineFlashStore>((set, get) => ({
|
|
22
24
|
target: null,
|
|
23
|
-
flash(key, rowId, opens) {
|
|
25
|
+
flash(key, rowId, opens, block) {
|
|
24
26
|
const nonce = (get().target?.nonce ?? 0) + 1;
|
|
25
|
-
set({ target: { key, rowId, opens, nonce } });
|
|
27
|
+
set({ target: { key, rowId, opens, nonce, ...(block ? { block } : {}) } });
|
|
26
28
|
const timer = setTimeout(() => {
|
|
27
29
|
if (get().target?.nonce === nonce) {
|
|
28
30
|
set({ target: null });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState } from 'react';
|
|
1
|
+
import { useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
|
|
@@ -34,7 +34,19 @@ function zoomed(view: View, factor: number, pointX: number, pointY: number): Vie
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/* An image on its own, large: the wheel and the buttons zoom, dragging pans, Escape closes. */
|
|
37
|
-
function Lightbox({
|
|
37
|
+
function Lightbox({
|
|
38
|
+
src,
|
|
39
|
+
alt,
|
|
40
|
+
open,
|
|
41
|
+
onOpenChange,
|
|
42
|
+
actions
|
|
43
|
+
}: {
|
|
44
|
+
src: string;
|
|
45
|
+
alt: string;
|
|
46
|
+
open: boolean;
|
|
47
|
+
onOpenChange(open: boolean): void;
|
|
48
|
+
actions?: ReactNode;
|
|
49
|
+
}) {
|
|
38
50
|
const { t } = useTranslation('agent-chat');
|
|
39
51
|
const [view, setView] = useState<View>(START);
|
|
40
52
|
// The alt of an image an agent read is its path; the header shows the file and keeps the path on a tooltip.
|
|
@@ -84,6 +96,7 @@ function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string;
|
|
|
84
96
|
/>
|
|
85
97
|
<IconButton icon={RotateCcw} size="sm" label={t('image.reset')} disabled={view.scale === MIN_SCALE} onClick={() => setView(START)} />
|
|
86
98
|
</ButtonGroup>
|
|
99
|
+
{actions}
|
|
87
100
|
<CloseButton label={t('common.action.close')} kbd="esc" className="ml-1" dialog />
|
|
88
101
|
</div>
|
|
89
102
|
<div
|
|
@@ -130,16 +143,41 @@ function Lightbox({ src, alt, open, onOpenChange }: { src: string; alt: string;
|
|
|
130
143
|
);
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
export interface ImageThumbProps {
|
|
147
|
+
source: ResourceUrl;
|
|
148
|
+
alt: string;
|
|
149
|
+
className?: string;
|
|
150
|
+
/* Width over height, known before the bytes: the thumbnail then fills its container's width at that ratio and holds its place while they load. */
|
|
151
|
+
aspect?: number;
|
|
152
|
+
/* Drawn in the lightbox's header before Close, such as icon buttons that act on the image. */
|
|
153
|
+
actions?: ReactNode;
|
|
154
|
+
/* Whether the lightbox is open, for a caller that opens it from a control of its own; left out, the thumbnail keeps it. */
|
|
155
|
+
open?: boolean;
|
|
156
|
+
onOpenChange?(open: boolean): void;
|
|
157
|
+
}
|
|
158
|
+
|
|
133
159
|
/*
|
|
134
160
|
* A thumbnail that opens the same image large. The button is the picture, so there is nothing to aim at.
|
|
135
161
|
* The image is bytes where the host keeps them; until they are here a quiet square stands in for it.
|
|
136
162
|
*/
|
|
137
|
-
export function ImageThumb({ source, alt, className
|
|
138
|
-
const [
|
|
163
|
+
export function ImageThumb({ source, alt, className, aspect, actions, open: openProp, onOpenChange }: ImageThumbProps) {
|
|
164
|
+
const [ownOpen, setOwnOpen] = useState(false);
|
|
165
|
+
const open = openProp ?? ownOpen;
|
|
166
|
+
const setOpen = (next: boolean): void => {
|
|
167
|
+
setOwnOpen(next);
|
|
168
|
+
onOpenChange?.(next);
|
|
169
|
+
};
|
|
139
170
|
const { url: src, failure } = source;
|
|
171
|
+
const ratio = aspect === undefined ? undefined : { aspectRatio: aspect };
|
|
140
172
|
if (src === null) {
|
|
141
173
|
return (
|
|
142
|
-
<span
|
|
174
|
+
<span
|
|
175
|
+
className={clsx(
|
|
176
|
+
'grid place-items-center rounded-lg border border-border bg-surface-sunken text-text-faint',
|
|
177
|
+
aspect === undefined ? 'size-16' : 'w-full'
|
|
178
|
+
)}
|
|
179
|
+
style={ratio}
|
|
180
|
+
>
|
|
143
181
|
{failure !== null && (
|
|
144
182
|
<Tooltip label={failure}>
|
|
145
183
|
<Icon icon={ImageOff} size={14} />
|
|
@@ -150,10 +188,15 @@ export function ImageThumb({ source, alt, className }: { source: ResourceUrl; al
|
|
|
150
188
|
}
|
|
151
189
|
return (
|
|
152
190
|
<>
|
|
153
|
-
<button
|
|
154
|
-
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
className={clsx('block overflow-hidden rounded-lg border border-border', aspect !== undefined && 'w-full')}
|
|
194
|
+
style={ratio}
|
|
195
|
+
onClick={() => setOpen(true)}
|
|
196
|
+
>
|
|
197
|
+
<img src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
|
|
155
198
|
</button>
|
|
156
|
-
<Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} />
|
|
199
|
+
<Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} actions={actions} />
|
|
157
200
|
</>
|
|
158
201
|
);
|
|
159
202
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { Bot, LoaderCircle } from 'lucide-react';
|
|
4
4
|
import { deriveTimelineRows } from '../logic/timeline';
|
|
@@ -9,6 +9,8 @@ import { EMPTY_TARGET, readTimelineTarget, type TimelineTarget } from '../logic/
|
|
|
9
9
|
import { openBelow, useSubagentTrail, type SubagentStep } from '../subagent-view';
|
|
10
10
|
import { Row } from './rows/Rows';
|
|
11
11
|
import { ReplyContext } from './reply-context';
|
|
12
|
+
import { PromptComposer } from './PromptComposer';
|
|
13
|
+
import { useProviders } from '../../state/providers';
|
|
12
14
|
import { SubagentInfo } from './SubagentInfo';
|
|
13
15
|
import { TimelineMenuPopup } from './TimelineMenu';
|
|
14
16
|
import { FOLLOW_THRESHOLD_PX, rowRhythm } from './rows/row-rhythm';
|
|
@@ -27,8 +29,14 @@ const NO_TURNS = new Set<string>();
|
|
|
27
29
|
export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
|
|
28
30
|
const { t } = useTranslation('agent-chat');
|
|
29
31
|
const scope = useChatScope();
|
|
32
|
+
const connected = useSyncExternalStore(
|
|
33
|
+
useCallback((changed) => scope.transport.subscribeStatus(changed), [scope]),
|
|
34
|
+
useCallback(() => scope.transport.status === 'open', [scope]),
|
|
35
|
+
() => false
|
|
36
|
+
);
|
|
30
37
|
const { trail, show } = useSubagentTrail(chatId);
|
|
31
38
|
const [state, setState] = useState<SubagentConversationState>(INITIAL_CONVERSATION);
|
|
39
|
+
const denyReason = useProviders((row) => row.providers.find((provider) => provider.kind === state.context?.provider)?.capabilities.denyReason === true);
|
|
32
40
|
const controller = useRef<SubagentConversation | null>(null);
|
|
33
41
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
34
42
|
const threadRef = useRef<HTMLDivElement>(null);
|
|
@@ -167,6 +175,21 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
|
|
|
167
175
|
</ReplyContext.Provider>
|
|
168
176
|
</FileLinkContext.Provider>
|
|
169
177
|
)}
|
|
178
|
+
{state.context?.chatId !== undefined && state.pending.length > 0 && (
|
|
179
|
+
<div className="chat-column-content shrink-0">
|
|
180
|
+
<PromptComposer
|
|
181
|
+
key={state.context.chatId}
|
|
182
|
+
chatId={state.context.chatId}
|
|
183
|
+
pending={state.pending}
|
|
184
|
+
focused={false}
|
|
185
|
+
disabled={!connected}
|
|
186
|
+
hasDraft={false}
|
|
187
|
+
denyReason={denyReason}
|
|
188
|
+
>
|
|
189
|
+
{null}
|
|
190
|
+
</PromptComposer>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
170
193
|
</div>
|
|
171
194
|
);
|
|
172
195
|
}
|
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) {
|
|
@@ -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
|
})}
|
|
@@ -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
|
+
}
|