@adecore/agents-react 0.21.2 → 0.22.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/agents/model-requirement.d.ts +6 -0
- package/dist/agents/model-requirement.js +26 -0
- package/dist/chat/chat-client.d.ts +2 -1
- package/dist/chat/chat-client.js +2 -0
- package/dist/chat/drafts.d.ts +2 -1
- package/dist/chat/drafts.js +15 -5
- package/dist/chat/logic/picker-entries.d.ts +10 -0
- package/dist/chat/logic/picker-entries.js +19 -0
- package/dist/chat/logic/timeline.d.ts +5 -0
- package/dist/chat/logic/timeline.js +10 -1
- package/dist/chat/prompt-hold.d.ts +8 -0
- package/dist/chat/prompt-hold.js +16 -0
- package/dist/chat/quote.d.ts +4 -0
- package/dist/chat/quote.js +19 -0
- package/dist/chat/stash.d.ts +8 -2
- package/dist/chat/stash.js +21 -5
- package/dist/chat/subagent-list.d.ts +7 -1
- package/dist/chat/subagent-list.js +13 -1
- package/dist/chat/timeline-scroll.d.ts +6 -2
- package/dist/chat/timeline-scroll.js +12 -7
- package/dist/chat/ui/ChatActivity.js +22 -8
- package/dist/chat/ui/Composer.js +103 -19
- package/dist/chat/ui/ComposerInput.d.ts +3 -1
- package/dist/chat/ui/ComposerInput.js +13 -4
- package/dist/chat/ui/Pickers.js +26 -18
- package/dist/chat/ui/PromptComposer.d.ts +8 -1
- package/dist/chat/ui/PromptComposer.js +18 -6
- package/dist/chat/ui/QuoteButton.js +2 -2
- package/dist/chat/ui/SubagentControls.d.ts +4 -2
- package/dist/chat/ui/SubagentControls.js +12 -8
- package/dist/chat/ui/SubagentInfo.d.ts +1 -1
- package/dist/chat/ui/SubagentInfo.js +9 -15
- package/dist/chat/ui/SubagentStopButton.js +2 -1
- package/dist/chat/ui/Timeline.js +24 -3
- package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
- package/dist/chat/ui/composer/menu-aria.js +18 -0
- package/dist/chat/ui/quote-selection.d.ts +5 -2
- package/dist/chat/ui/quote-selection.js +3 -2
- package/dist/chat/ui/rows/MessageRows.js +24 -5
- package/dist/chat/ui/rows/Rows.js +1 -1
- package/dist/chat/ui/rows/WorkRows.d.ts +3 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -4
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +30 -7
- package/dist/locales/nl/agent-chat.json +30 -7
- package/dist/state/chats.d.ts +2 -1
- package/dist/state/chats.js +19 -1
- package/package.json +24 -4
- package/src/agents/model-requirement.ts +36 -0
- package/src/chat/chat-client.ts +5 -0
- package/src/chat/drafts.ts +27 -7
- package/src/chat/logic/picker-entries.ts +25 -0
- package/src/chat/logic/timeline.ts +17 -2
- package/src/chat/prompt-hold.ts +27 -0
- package/src/chat/quote.ts +20 -0
- package/src/chat/stash.ts +31 -5
- package/src/chat/subagent-list.ts +19 -5
- package/src/chat/timeline-scroll.ts +24 -10
- package/src/chat/ui/ChatActivity.tsx +35 -9
- package/src/chat/ui/Composer.tsx +197 -52
- package/src/chat/ui/ComposerInput.tsx +18 -3
- package/src/chat/ui/Pickers.tsx +51 -30
- package/src/chat/ui/PromptComposer.tsx +37 -8
- package/src/chat/ui/QuoteButton.tsx +3 -2
- package/src/chat/ui/SubagentControls.tsx +22 -19
- package/src/chat/ui/SubagentInfo.tsx +24 -42
- package/src/chat/ui/SubagentStopButton.tsx +2 -1
- package/src/chat/ui/Timeline.tsx +26 -4
- package/src/chat/ui/composer/menu-aria.ts +29 -0
- package/src/chat/ui/quote-selection.ts +6 -4
- package/src/chat/ui/rows/MessageRows.tsx +44 -5
- package/src/chat/ui/rows/Rows.tsx +3 -1
- package/src/chat/ui/rows/WorkRows.tsx +15 -1
- package/src/host.ts +3 -0
- package/src/locales/en/agent-chat.json +30 -7
- package/src/locales/nl/agent-chat.json +30 -7
- package/src/state/chats.ts +31 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { ArrowDown, Hand, MessageCircleQuestionMark } from 'lucide-react';
|
|
4
|
+
import type { HoldRelease } from '../prompt-hold';
|
|
4
5
|
import { focusPromptHeading, focusPromptStart } from '../../prompts/logic/focus';
|
|
5
6
|
import { orderPrompts, type PendingPrompt } from '../../prompts/logic/prompts';
|
|
6
7
|
import { answerPrompt, isBlockingSubject, promptCreatedAt, promptIdOf, type PromptSubject } from '../../prompts/logic/subjects';
|
|
@@ -10,7 +11,14 @@ import { PromptView } from '../../prompts/ui/PromptView';
|
|
|
10
11
|
import { chatHost } from '../../host';
|
|
11
12
|
import { useChatScope } from '../../scope';
|
|
12
13
|
import { useChatActions } from '../actions';
|
|
13
|
-
import { Icon } from '@adecore/ui';
|
|
14
|
+
import { Button, Icon } from '@adecore/ui';
|
|
15
|
+
|
|
16
|
+
/* Keeps a prompt card behind the draft while the person types; `released` says what lifted the hold last. */
|
|
17
|
+
export interface PromptHold {
|
|
18
|
+
held: boolean;
|
|
19
|
+
releasedBy: HoldRelease | null;
|
|
20
|
+
release(reason: HoldRelease): void;
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
/* The one being read stays in front; otherwise blocking before optional, oldest first, as everywhere else. */
|
|
16
24
|
function pickSubject(waiting: readonly PromptSubject[], activeId: string | null): PromptSubject | null {
|
|
@@ -26,6 +34,7 @@ export function PromptComposer({
|
|
|
26
34
|
hasDraft,
|
|
27
35
|
denyReason,
|
|
28
36
|
onAllAnswered,
|
|
37
|
+
hold,
|
|
29
38
|
children
|
|
30
39
|
}: {
|
|
31
40
|
chatId: string;
|
|
@@ -38,6 +47,8 @@ export function PromptComposer({
|
|
|
38
47
|
denyReason: boolean;
|
|
39
48
|
/* Takes the keyboard back to the composer once the last prompt was answered from its card. */
|
|
40
49
|
onAllAnswered?: () => void;
|
|
50
|
+
/* Without it a card takes the composer's place at once. */
|
|
51
|
+
hold?: PromptHold;
|
|
41
52
|
children: ReactNode;
|
|
42
53
|
}) {
|
|
43
54
|
const { t } = useTranslation('agent-chat');
|
|
@@ -56,10 +67,23 @@ export function PromptComposer({
|
|
|
56
67
|
const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
|
|
57
68
|
const ref = useRef<HTMLDivElement>(null);
|
|
58
69
|
const { active, activeId: activeKey } = session;
|
|
59
|
-
const
|
|
70
|
+
const asksApproval = active?.kind === 'chat' ? active.item.kind === 'approval' : active?.prompt.asks === 'approval';
|
|
71
|
+
const held = !!active && (hold?.held ?? false);
|
|
72
|
+
const expanded = !!active && !held;
|
|
73
|
+
// The card that waited behind the draft, so a blur that released it is not mistaken for one released long before.
|
|
74
|
+
const heldKey = useRef<string | null>(null);
|
|
60
75
|
const refocus = useFocusAfterAnswer(activeKey, ref, () => onAllAnswered?.());
|
|
61
76
|
|
|
62
77
|
useEffect(() => {
|
|
78
|
+
if (held) {
|
|
79
|
+
heldKey.current = activeKey;
|
|
80
|
+
}
|
|
81
|
+
}, [held, activeKey]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (hold?.releasedBy === 'blur' && heldKey.current === activeKey) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
63
87
|
if (focused && expanded && !elsewhere && !ref.current?.querySelector('.prompt-card')?.contains(document.activeElement)) {
|
|
64
88
|
// Keys meant for the draft may still be coming, and on a button the next Space would answer the card.
|
|
65
89
|
if (hasDraft) {
|
|
@@ -68,7 +92,7 @@ export function PromptComposer({
|
|
|
68
92
|
focusPromptStart(ref.current);
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
|
-
}, [activeKey, expanded, focused, elsewhere, hasDraft]);
|
|
95
|
+
}, [activeKey, expanded, focused, elsewhere, hasDraft, hold?.releasedBy]);
|
|
72
96
|
|
|
73
97
|
return (
|
|
74
98
|
<div ref={ref} onPointerDownCapture={session.onPointerDownCapture} onClickCapture={session.onClickCapture}>
|
|
@@ -78,11 +102,7 @@ export function PromptComposer({
|
|
|
78
102
|
className="flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text"
|
|
79
103
|
onClick={onShowElsewhere}
|
|
80
104
|
>
|
|
81
|
-
<Icon
|
|
82
|
-
icon={(active.kind === 'chat' ? active.item.kind === 'approval' : active.prompt.asks === 'approval') ? Hand : MessageCircleQuestionMark}
|
|
83
|
-
size={16}
|
|
84
|
-
className="shrink-0 text-status-needs-you"
|
|
85
|
-
/>
|
|
105
|
+
<Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
|
|
86
106
|
<span className="grow">{t('composer.waitingBelow')}</span>
|
|
87
107
|
<Icon icon={ArrowDown} size={16} className="shrink-0" />
|
|
88
108
|
</button>
|
|
@@ -113,6 +133,15 @@ export function PromptComposer({
|
|
|
113
133
|
/>
|
|
114
134
|
</div>
|
|
115
135
|
)}
|
|
136
|
+
{held && active && (
|
|
137
|
+
<div role="status" className="flex items-center gap-2 border-b border-border px-3.5 py-2.5">
|
|
138
|
+
<Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
|
|
139
|
+
<span className="grow text-sm text-text-muted">{t(asksApproval ? 'composer.held.approval' : 'composer.held.question')}</span>
|
|
140
|
+
<Button size="sm" onMouseDown={(e) => e.preventDefault()} onClick={() => hold?.release('show')}>
|
|
141
|
+
{t('composer.held.show')}
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
116
145
|
<div hidden={expanded}>{children}</div>
|
|
117
146
|
</div>
|
|
118
147
|
);
|
|
@@ -10,6 +10,7 @@ const EDGE_PX = 40;
|
|
|
10
10
|
|
|
11
11
|
interface Offer {
|
|
12
12
|
quote: string;
|
|
13
|
+
itemId: string | null;
|
|
13
14
|
x: number;
|
|
14
15
|
y: number;
|
|
15
16
|
}
|
|
@@ -52,7 +53,7 @@ export function QuoteButton({
|
|
|
52
53
|
const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
|
|
53
54
|
const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
|
|
54
55
|
const y = (line.bottom - bounds.top) / zoom + GAP_PX;
|
|
55
|
-
setOffer({ quote, x: Math.round(x), y: Math.round(y) });
|
|
56
|
+
setOffer({ quote: quote.text, itemId: quote.itemId, x: Math.round(x), y: Math.round(y) });
|
|
56
57
|
};
|
|
57
58
|
const press = (): void => {
|
|
58
59
|
pressed = true;
|
|
@@ -91,7 +92,7 @@ export function QuoteButton({
|
|
|
91
92
|
onMouseDown={(e) => e.preventDefault()}
|
|
92
93
|
onClick={() => {
|
|
93
94
|
document.getSelection()?.removeAllRanges();
|
|
94
|
-
taker.current?.(offer.quote);
|
|
95
|
+
taker.current?.(offer.quote, offer.itemId);
|
|
95
96
|
setOffer(null);
|
|
96
97
|
}}
|
|
97
98
|
>
|
|
@@ -21,31 +21,18 @@ export function SubagentTitleCrumb({ chatId, className, children }: { chatId: st
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/*
|
|
25
|
-
|
|
26
|
-
* to the main agent. A bar that does not draw the title itself passes it, and it opens the way.
|
|
27
|
-
* Nothing while the main agent itself is on screen.
|
|
28
|
-
*/
|
|
29
|
-
export function SubagentBreadcrumb({ chatId, title, className }: { chatId: string; title?: string; className?: string }) {
|
|
24
|
+
/* The way down to the sub-agent on screen, each step but the last a way back up. Nothing while the main agent is on screen. */
|
|
25
|
+
export function SubagentBreadcrumb({ chatId, className }: { chatId: string; className?: string }) {
|
|
30
26
|
const { t } = useTranslation('agent-chat');
|
|
31
27
|
const { trail, show } = useSubagentTrail(chatId);
|
|
32
|
-
const subagents = useOpenableSubagents(chatId);
|
|
33
28
|
if (trail.length === 0) {
|
|
34
29
|
return null;
|
|
35
30
|
}
|
|
36
|
-
const steps = breadcrumbOf(trail);
|
|
37
|
-
// Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
|
|
38
|
-
const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
|
|
39
31
|
return (
|
|
40
|
-
<nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1
|
|
41
|
-
{
|
|
42
|
-
<button type="button" className={clsx(CRUMB_LINK, 'min-w-0 shrink')} onClick={() => show(MAIN_AGENT)}>
|
|
43
|
-
{title}
|
|
44
|
-
</button>
|
|
45
|
-
)}
|
|
46
|
-
{steps.map((step) => (
|
|
32
|
+
<nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1', className)}>
|
|
33
|
+
{breadcrumbOf(trail).map((step) => (
|
|
47
34
|
<Fragment key={step.depth}>
|
|
48
|
-
<Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />
|
|
35
|
+
{step.depth > 1 && <Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />}
|
|
49
36
|
{step.current ? (
|
|
50
37
|
<span aria-current="page" className="min-w-0 truncate text-text">
|
|
51
38
|
{step.label}
|
|
@@ -57,8 +44,24 @@ export function SubagentBreadcrumb({ chatId, title, className }: { chatId: strin
|
|
|
57
44
|
)}
|
|
58
45
|
</Fragment>
|
|
59
46
|
))}
|
|
47
|
+
</nav>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Stops the sub-agent on screen where that is on offer, and closes it straight back to the main agent. */
|
|
52
|
+
export function SubagentActions({ chatId }: { chatId: string }) {
|
|
53
|
+
const { t } = useTranslation('agent-chat');
|
|
54
|
+
const { trail, show } = useSubagentTrail(chatId);
|
|
55
|
+
const subagents = useOpenableSubagents(chatId);
|
|
56
|
+
if (trail.length === 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
|
|
60
|
+
const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
60
63
|
{shown !== undefined && <SubagentStopButton chatId={chatId} item={shown} size="sm" />}
|
|
61
64
|
<IconButton icon={X} size="sm" label={t('subagents.backToMain')} onClick={() => show(MAIN_AGENT)} />
|
|
62
|
-
</
|
|
65
|
+
</div>
|
|
63
66
|
);
|
|
64
67
|
}
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { Fragment
|
|
2
|
-
import clsx from 'clsx';
|
|
1
|
+
import { Fragment } from 'react';
|
|
3
2
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { ChevronDown } from 'lucide-react';
|
|
5
3
|
import { entryTimeOf, statusWordOf, subagentFacts, taskIdOf } from '../subagent-list';
|
|
6
4
|
import { useSubagentItem } from '../subagent-view';
|
|
7
5
|
import { StatusIcon } from './ChatActivity';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { SubagentActions, SubagentBreadcrumb } from './SubagentControls';
|
|
7
|
+
import { useNow } from '@adecore/ui';
|
|
10
8
|
import { useModelName } from '../../agents/model-name';
|
|
11
9
|
import { useChatRow } from '../../state/chats';
|
|
12
10
|
import { chatHost } from '../../host';
|
|
13
11
|
import { useChatScope } from '../../scope';
|
|
14
12
|
|
|
15
13
|
/*
|
|
16
|
-
* What a sub-agent is, over its conversation: its state and time, the model it runs on
|
|
17
|
-
* spent, and the
|
|
14
|
+
* What a sub-agent is, over its conversation: the way down to it, its state and time, the model it runs on
|
|
15
|
+
* and what it spent, and the way out. Only the way down and out for a conversation whose row the thread lost.
|
|
18
16
|
*/
|
|
19
17
|
export function SubagentInfo({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
|
|
20
18
|
const { t } = useTranslation('agent-chat');
|
|
@@ -24,43 +22,27 @@ export function SubagentInfo({ chatId, toolUseId }: { chatId: string; toolUseId:
|
|
|
24
22
|
const task = chatHost().tasks.useTask(id, item === null ? null : taskIdOf(item));
|
|
25
23
|
const modelName = useModelName(provider, item?.model ?? '');
|
|
26
24
|
const now = useNow(1000, item?.status === 'running');
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
const word = statusWordOf(item, task);
|
|
32
|
-
const facts = subagentFacts(item, item.model === undefined ? null : modelName);
|
|
25
|
+
const word = item === null ? null : statusWordOf(item, task);
|
|
26
|
+
const facts = item === null ? [] : subagentFacts(item, item.model === undefined ? null : modelName);
|
|
33
27
|
return (
|
|
34
|
-
<section aria-label={t('subagents.info.label')} className="shrink-0 border-b border-border px-4 py-
|
|
35
|
-
<div className="
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
<span className="min-w-0 truncate">{fact}</span>
|
|
44
|
-
</Fragment>
|
|
45
|
-
))}
|
|
46
|
-
<span className="grow" />
|
|
47
|
-
{item.prompt && (
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
className="flex shrink-0 items-center gap-1.5 text-text-muted hover:text-text"
|
|
51
|
-
aria-expanded={open}
|
|
52
|
-
onClick={() => setOpen((current) => !current)}
|
|
53
|
-
>
|
|
54
|
-
<Icon icon={ChevronDown} size={12} className={clsx('transition-transform', open && 'rotate-180')} />
|
|
55
|
-
{open ? t('subagents.info.hideTask') : t('subagents.info.showTask')}
|
|
56
|
-
</button>
|
|
57
|
-
)}
|
|
58
|
-
</div>
|
|
59
|
-
{open && item.prompt && (
|
|
60
|
-
<div className="max-h-60 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-xs select-text">
|
|
61
|
-
<Markdown text={item.prompt} />
|
|
62
|
-
</div>
|
|
28
|
+
<section aria-label={t('subagents.info.label')} className="shrink-0 border-b border-border px-4 py-1.5">
|
|
29
|
+
<div className="flex min-w-0 items-center gap-2 text-xs text-text-faint">
|
|
30
|
+
<SubagentBreadcrumb chatId={chatId} className="max-w-1/3 shrink-0 font-medium" />
|
|
31
|
+
{item !== null && word !== null && (
|
|
32
|
+
<>
|
|
33
|
+
<StatusIcon word={word} />
|
|
34
|
+
<span className="sr-only">{t(`common.status.${word}`)}</span>
|
|
35
|
+
<span className="shrink-0 text-text-muted tabular-nums">{entryTimeOf(item, task, now)}</span>
|
|
36
|
+
</>
|
|
63
37
|
)}
|
|
38
|
+
{facts.map((fact) => (
|
|
39
|
+
<Fragment key={fact}>
|
|
40
|
+
<span aria-hidden="true">·</span>
|
|
41
|
+
<span className="min-w-0 truncate">{fact}</span>
|
|
42
|
+
</Fragment>
|
|
43
|
+
))}
|
|
44
|
+
<span className="grow" />
|
|
45
|
+
<SubagentActions chatId={chatId} />
|
|
64
46
|
</div>
|
|
65
47
|
</section>
|
|
66
48
|
);
|
|
@@ -30,7 +30,8 @@ export function SubagentStopButton({
|
|
|
30
30
|
}
|
|
31
31
|
const send = (): void => {
|
|
32
32
|
void actions.stopSubagent(chatId, item.toolUseId).catch((e: unknown) => {
|
|
33
|
-
const
|
|
33
|
+
const stale = e instanceof Error && 'code' in e && e.code === 'subagent-stale';
|
|
34
|
+
const description = stale ? t('subagents.stale') : e instanceof Error ? e.message : t('subagents.noAnswer');
|
|
34
35
|
chatHost().notify({ kind: 'error', title: t('subagents.stopFailed'), description });
|
|
35
36
|
});
|
|
36
37
|
};
|
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
registerTurnJumper,
|
|
17
17
|
setTimelineAtEnd,
|
|
18
18
|
wantsEarlier,
|
|
19
|
+
type ItemJumpOptions,
|
|
19
20
|
type ReadingAnchor,
|
|
20
21
|
type TurnTarget
|
|
21
22
|
} from '../timeline-scroll';
|
|
@@ -66,6 +67,8 @@ import { useModelName } from '../../agents/model-name';
|
|
|
66
67
|
import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@adecore/ui';
|
|
67
68
|
|
|
68
69
|
const ESTIMATED_ROW_PX = 56;
|
|
70
|
+
// How long a jump waits for a thread that is still loading before it calls the item missing.
|
|
71
|
+
const JUMP_THREAD_WAIT_MS = 8000;
|
|
69
72
|
|
|
70
73
|
/* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
|
|
71
74
|
function showRowAtTop(virtualizer: Virtualizer<HTMLDivElement, Element>, followRef: RefObject<boolean>, index: number): void {
|
|
@@ -222,7 +225,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
222
225
|
const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
|
|
223
226
|
const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
|
|
224
227
|
// A message a jump is on its way to; it may first have to open the turn its reply folded into.
|
|
225
|
-
const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string } | null>(null);
|
|
228
|
+
const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string; options?: ItemJumpOptions } | null>(null);
|
|
226
229
|
const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
|
|
227
230
|
// A turn a jump is on its way to, which waits for the page its messages are on to come in.
|
|
228
231
|
const [revealing, setRevealing] = useState<{ turnId: string; target: TurnTarget } | null>(null);
|
|
@@ -451,9 +454,16 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
451
454
|
if (index !== -1) {
|
|
452
455
|
setSeeking(null);
|
|
453
456
|
showRowAtTop(virtualizer, followRef, index);
|
|
457
|
+
if (seeking.options?.flash) {
|
|
458
|
+
useTimelineFlash.getState().flash(seeking.key, seeking.itemId, false);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// The thread has not loaded yet, so nothing says the item is missing; the effect below bounds the wait.
|
|
463
|
+
if (items === undefined) {
|
|
454
464
|
return;
|
|
455
465
|
}
|
|
456
|
-
const item = items
|
|
466
|
+
const item = items[seeking.itemId];
|
|
457
467
|
if (item === undefined && cursor !== null) {
|
|
458
468
|
loadEarlier();
|
|
459
469
|
return;
|
|
@@ -464,12 +474,24 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
464
474
|
return;
|
|
465
475
|
}
|
|
466
476
|
setSeeking(null);
|
|
477
|
+
seeking.options?.onMissing?.();
|
|
467
478
|
}, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
|
|
479
|
+
const waitingForThread = seeking !== null && items === undefined;
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (!waitingForThread) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const timer = window.setTimeout(() => {
|
|
485
|
+
setSeeking(null);
|
|
486
|
+
seeking?.options?.onMissing?.();
|
|
487
|
+
}, JUMP_THREAD_WAIT_MS);
|
|
488
|
+
return () => window.clearTimeout(timer);
|
|
489
|
+
}, [waitingForThread, seeking]);
|
|
468
490
|
useEffect(
|
|
469
491
|
() =>
|
|
470
|
-
registerItemJumper(keyOf(chatId), (itemId) => {
|
|
492
|
+
registerItemJumper(keyOf(chatId), (itemId, options) => {
|
|
471
493
|
setLanding(null);
|
|
472
|
-
setSeeking({ itemId, key: keyOf(chatId) });
|
|
494
|
+
setSeeking({ itemId, key: keyOf(chatId), options });
|
|
473
495
|
}),
|
|
474
496
|
[keyOf, chatId]
|
|
475
497
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ComposerMenuAriaInput {
|
|
2
|
+
label: string;
|
|
3
|
+
listId: string;
|
|
4
|
+
open: boolean;
|
|
5
|
+
/* Counts across every group of the menu, the way the keyboard moves through it. */
|
|
6
|
+
activeIndex: number;
|
|
7
|
+
count: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* The index that still points at an option once the list has shrunk under a stale one. */
|
|
11
|
+
export function clampMenuIndex(index: number, count: number): number {
|
|
12
|
+
return Math.min(index, Math.max(count - 1, 0));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function optionIdOf(listId: string, index: number): string {
|
|
16
|
+
return `${listId}-${index}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* The attributes the editor's content element takes while a suggestion menu stands beside it. The
|
|
21
|
+
* element keeps its `textbox` role; ARIA 1.2 lets it name the option the arrow keys are on.
|
|
22
|
+
*/
|
|
23
|
+
export function composerMenuAria({ label, listId, open, activeIndex, count }: ComposerMenuAriaInput): Record<string, string> {
|
|
24
|
+
return {
|
|
25
|
+
'aria-label': label,
|
|
26
|
+
'aria-autocomplete': 'list',
|
|
27
|
+
...(open && count > 0 ? { 'aria-controls': listId, 'aria-activedescendant': optionIdOf(listId, activeIndex) } : {})
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -4,7 +4,8 @@ import { quoteOf } from '../quote';
|
|
|
4
4
|
/* The text of an answer (`rows/MessageRows.tsx`), the only part of a thread a selection quotes from. */
|
|
5
5
|
const ANSWER = '[data-quote-answer]';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/* `itemId` is the reply the quote came from, when the selection sits in a row that names one. */
|
|
8
|
+
export type QuoteTaker = (quote: string, itemId: string | null) => void;
|
|
8
9
|
|
|
9
10
|
/* Registers the composer below a thread as the one its quote button hands a quote to, until the returned call. */
|
|
10
11
|
export const QuoteTakerContext = createContext<((take: QuoteTaker) => () => void) | null>(null);
|
|
@@ -35,8 +36,8 @@ function selectsOutside(range: Range, answer: Element): boolean {
|
|
|
35
36
|
return false;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/* The quote the selection makes, when it lies in one answer of this thread. */
|
|
39
|
-
export function selectedAnswerQuote(selection: Selection | null, thread: HTMLElement | null): string | null {
|
|
39
|
+
/* The quote the selection makes, when it lies in one answer of this thread, and the reply it was taken from. */
|
|
40
|
+
export function selectedAnswerQuote(selection: Selection | null, thread: HTMLElement | null): { text: string; itemId: string | null } | null {
|
|
40
41
|
if (thread === null || selection === null || selection.isCollapsed || selection.rangeCount === 0) {
|
|
41
42
|
return null;
|
|
42
43
|
}
|
|
@@ -49,5 +50,6 @@ export function selectedAnswerQuote(selection: Selection | null, thread: HTMLEle
|
|
|
49
50
|
if (!inside && selectsOutside(range, answer)) {
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
const text = quoteOf(selection.toString());
|
|
54
|
+
return text === null ? null : { text, itemId: answer.closest('[data-find-item]')?.getAttribute('data-find-item') ?? null };
|
|
53
55
|
}
|
|
@@ -11,10 +11,13 @@ import type {
|
|
|
11
11
|
ChatUiChoiceOrigin,
|
|
12
12
|
ChatUserItem
|
|
13
13
|
} from '@adecore/agent-contracts';
|
|
14
|
+
import { WAITING_NOTE_PREFIX } from '@adecore/agent-contracts';
|
|
14
15
|
import { fileBadge, formatBytes, isImageAttachment } from '../../attachments';
|
|
15
16
|
import { useChatRow, useCurrentItem } from '../../../state/chats';
|
|
16
17
|
import { chatHost } from '../../../host';
|
|
17
18
|
import { useChatScope } from '../../../scope';
|
|
19
|
+
import { splitQuote } from '../../quote';
|
|
20
|
+
import { jumpToTimelineItem } from '../../timeline-scroll';
|
|
18
21
|
import { useProviders } from '../../../state/providers';
|
|
19
22
|
import { ChatReferenceChip } from '../ChatReferenceChip';
|
|
20
23
|
import { ImageThumb } from '../ImageView';
|
|
@@ -86,7 +89,19 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
|
|
|
86
89
|
const long = item.text.split('\n').length > USER_FOLD_LINES || item.text.length > USER_FOLD_CHARS;
|
|
87
90
|
const attachments = item.attachments ?? [];
|
|
88
91
|
const chats = item.chats ?? [];
|
|
92
|
+
const [missing, setMissing] = useState(false);
|
|
93
|
+
const { keyOf } = useChatScope();
|
|
89
94
|
useOpenForFind(item.id, 'text', setOpen);
|
|
95
|
+
// A quote only leads somewhere when the message still opens with the blockquote it was sent with.
|
|
96
|
+
const quoted = item.quoteSource === undefined ? null : splitQuote(item.text);
|
|
97
|
+
const source = item.quoteSource?.itemId;
|
|
98
|
+
|
|
99
|
+
function goToSource(): void {
|
|
100
|
+
if (source !== undefined) {
|
|
101
|
+
setMissing(false);
|
|
102
|
+
jumpToTimelineItem(keyOf(chatId), source, { flash: true, onMissing: () => setMissing(true) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
90
105
|
return (
|
|
91
106
|
<div data-find-item={item.id} className="flex flex-col items-end">
|
|
92
107
|
<MessageHeading>{t('rows.user.heading')}</MessageHeading>
|
|
@@ -112,7 +127,31 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
|
|
|
112
127
|
{item.text !== '' && (
|
|
113
128
|
<div className="relative max-w-[80%] rounded-2xl bg-surface-active px-3.5 py-2.5 text-sm wrap-anywhere text-text select-text">
|
|
114
129
|
<div data-find-field="text" className={clsx(long && !open && FOLD)}>
|
|
115
|
-
|
|
130
|
+
{quoted !== null && source !== undefined ? (
|
|
131
|
+
<>
|
|
132
|
+
{/* A div, since the quote is block markdown and a button holds none. */}
|
|
133
|
+
<Tooltip label={t('composer.quote.goToSource')}>
|
|
134
|
+
<div
|
|
135
|
+
role="button"
|
|
136
|
+
tabIndex={0}
|
|
137
|
+
className="mb-2 cursor-pointer rounded-sm border-l-2 border-border-strong pl-2.5 text-text-muted hover:bg-text/5 hover:text-text focus-visible:outline-2 focus-visible:outline-accent"
|
|
138
|
+
onClick={goToSource}
|
|
139
|
+
onKeyDown={(e) => {
|
|
140
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
goToSource();
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<MessageMarkdown text={quoted.quote} />
|
|
147
|
+
</div>
|
|
148
|
+
</Tooltip>
|
|
149
|
+
{missing && <div className="mb-2 text-xs text-text-faint">{t('rows.user.quoteGone')}</div>}
|
|
150
|
+
{quoted.rest !== '' && <MessageMarkdown text={quoted.rest} mentions={item.mentions} skills={item.skills} />}
|
|
151
|
+
</>
|
|
152
|
+
) : (
|
|
153
|
+
<MessageMarkdown text={item.text} mentions={item.mentions} skills={item.skills} />
|
|
154
|
+
)}
|
|
116
155
|
</div>
|
|
117
156
|
{long && (
|
|
118
157
|
<button className="mt-1 flex items-center gap-1 text-xs text-text-muted hover:text-text" onClick={() => setOpen((o) => !o)}>
|
|
@@ -364,7 +403,7 @@ export function NoteRow({ id, level, text, from }: { id: string; level: 'info' |
|
|
|
364
403
|
{open ? t('rows.note.hide') : t('rows.note.show')}
|
|
365
404
|
</button>
|
|
366
405
|
)}
|
|
367
|
-
{from !== undefined && <OpenChatButton chatId={from} />}
|
|
406
|
+
{from !== undefined && <OpenChatButton chatId={from} label={id.startsWith(WAITING_NOTE_PREFIX) ? 'openWaiting' : 'openFork'} />}
|
|
368
407
|
</div>
|
|
369
408
|
{open && rest !== '' && (
|
|
370
409
|
<div data-find-field="output" className="mt-1 text-text-muted select-text">
|
|
@@ -376,8 +415,8 @@ export function NoteRow({ id, level, text, from }: { id: string; level: 'info' |
|
|
|
376
415
|
);
|
|
377
416
|
}
|
|
378
417
|
|
|
379
|
-
/* "Open fork" on a note a fork sent, while that
|
|
380
|
-
function OpenChatButton({ chatId }: { chatId: string }) {
|
|
418
|
+
/* "Open fork" on a note a fork sent, or "Open to answer" on one about a waiting child, while that chat is still in the project. */
|
|
419
|
+
function OpenChatButton({ chatId, label }: { chatId: string; label: 'openFork' | 'openWaiting' }) {
|
|
381
420
|
const { t } = useTranslation('agent-chat');
|
|
382
421
|
const place = chatHost().useChatPlace(chatId);
|
|
383
422
|
if (place.title === null) {
|
|
@@ -385,7 +424,7 @@ function OpenChatButton({ chatId }: { chatId: string }) {
|
|
|
385
424
|
}
|
|
386
425
|
return (
|
|
387
426
|
<button className="text-text-muted hover:text-text" onClick={place.go}>
|
|
388
|
-
{t(
|
|
427
|
+
{t(`rows.note.${label}`)}
|
|
389
428
|
</button>
|
|
390
429
|
);
|
|
391
430
|
}
|
|
@@ -67,7 +67,9 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
|
|
|
67
67
|
case 'forks':
|
|
68
68
|
return <ForksRow chatId={chatId} turnId={row.turnId} />;
|
|
69
69
|
case 'changed-files':
|
|
70
|
-
return
|
|
70
|
+
return (
|
|
71
|
+
<ChangedFilesRow chatId={chatId} turnId={row.turnId} tools={row.tools} diff={row.diff} checkpoint={row.checkpoint} baseMoved={row.baseMoved} />
|
|
72
|
+
);
|
|
71
73
|
case 'approval':
|
|
72
74
|
return <ApprovalHistoryRow item={row.item} />;
|
|
73
75
|
case 'question':
|
|
@@ -4,6 +4,7 @@ import i18next from 'i18next';
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { ChevronRight, FileDiff, X } from 'lucide-react';
|
|
6
6
|
import type { ChatCheckpointDiff, ChatCheckpointFile, ChatFileChange, ChatToolItem, ChatTurnItem } from '@adecore/agent-contracts';
|
|
7
|
+
import type { BaseMoved } from '../../logic/timeline';
|
|
7
8
|
import { fileChanges, liveOutput, readImagePath, toolStartedAt, toolSummary, unifiedChanges, type FileChange } from '../../logic/tools';
|
|
8
9
|
import { useOpenForFind } from '../find-reveal';
|
|
9
10
|
import { chatHost } from '../../../host';
|
|
@@ -289,10 +290,13 @@ function ChangedFilesCard({
|
|
|
289
290
|
added,
|
|
290
291
|
deleted,
|
|
291
292
|
revealNonce = 0,
|
|
293
|
+
baseMoved,
|
|
292
294
|
children
|
|
293
295
|
}: {
|
|
294
296
|
count: number;
|
|
295
297
|
truncated?: boolean;
|
|
298
|
+
/* Set when the branch moved during the turn: the count is then what changed in the folder, not only the agent's work. */
|
|
299
|
+
baseMoved?: BaseMoved | undefined;
|
|
296
300
|
added?: number;
|
|
297
301
|
deleted?: number;
|
|
298
302
|
/* A jump from outside the chat that asked for this card; each new one opens it. */
|
|
@@ -315,13 +319,20 @@ function ChangedFilesCard({
|
|
|
315
319
|
onClick={() => setExpanded((value) => !value)}
|
|
316
320
|
>
|
|
317
321
|
<Icon icon={FileDiff} size={12} className="shrink-0" />
|
|
318
|
-
<span className="shrink-0 font-medium text-text">
|
|
322
|
+
<span className="shrink-0 font-medium text-text">
|
|
323
|
+
{baseMoved === undefined ? t('work.changedFiles', { count }) : t('work.folderChanges', { count })}
|
|
324
|
+
</span>
|
|
319
325
|
{truncated && <span className="min-w-0 truncate text-text-faint">{t('work.andMore')}</span>}
|
|
320
326
|
<span className="grow" />
|
|
321
327
|
{added !== undefined && <span className="text-chat-added tabular-nums">+{added}</span>}
|
|
322
328
|
{deleted !== undefined && <span className="text-chat-removed tabular-nums">-{deleted}</span>}
|
|
323
329
|
<Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', expanded && 'rotate-90')} />
|
|
324
330
|
</button>
|
|
331
|
+
{baseMoved !== undefined && (
|
|
332
|
+
<div className="pr-3 pb-2 pl-8 text-xs text-text-faint">
|
|
333
|
+
{t('work.baseMoved', { from: baseMoved.from.slice(0, 7), to: baseMoved.to.slice(0, 7) })}
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
325
336
|
{expanded && children}
|
|
326
337
|
</div>
|
|
327
338
|
);
|
|
@@ -337,12 +348,14 @@ export function ChangedFilesRow({
|
|
|
337
348
|
tools,
|
|
338
349
|
diff,
|
|
339
350
|
checkpoint,
|
|
351
|
+
baseMoved,
|
|
340
352
|
chatId,
|
|
341
353
|
turnId
|
|
342
354
|
}: {
|
|
343
355
|
tools: ChatToolItem[];
|
|
344
356
|
diff: ChatCheckpointDiff | null;
|
|
345
357
|
checkpoint: boolean;
|
|
358
|
+
baseMoved?: BaseMoved | undefined;
|
|
346
359
|
chatId: string;
|
|
347
360
|
turnId: string;
|
|
348
361
|
}) {
|
|
@@ -375,6 +388,7 @@ export function ChangedFilesRow({
|
|
|
375
388
|
return (
|
|
376
389
|
<ChangedFilesCard
|
|
377
390
|
revealNonce={revealNonce}
|
|
391
|
+
baseMoved={baseMoved}
|
|
378
392
|
count={files.length}
|
|
379
393
|
truncated={checkpointDiff.truncated}
|
|
380
394
|
added={files.reduce((sum, file) => sum + file.added, 0)}
|
package/src/host.ts
CHANGED
|
@@ -279,6 +279,8 @@ export interface ChatHost {
|
|
|
279
279
|
useLoginBlocked(scopeId: string): string | null;
|
|
280
280
|
/* The name and the mark of a project of the host, for the usage per project; null draws the folder's own. */
|
|
281
281
|
useProjectLook(scopeId: string, projectId: string | null): { name: string; mark: ReactNode } | null;
|
|
282
|
+
/* What a person calls the machine the scope's chats run on, or null when it has no name worth saying. */
|
|
283
|
+
useMachineLabel(scopeId: string): string | null;
|
|
282
284
|
/* Where another chat of the app stands, for a fork's way back and a note from a fork. */
|
|
283
285
|
useChatPlace(chatId: string): ChatPlace;
|
|
284
286
|
/* What a chat may read besides its folder, named under an empty thread. */
|
|
@@ -345,6 +347,7 @@ const DEFAULT_HOST: ChatHost = {
|
|
|
345
347
|
openLogin: null,
|
|
346
348
|
useLoginBlocked: () => null,
|
|
347
349
|
useProjectLook: () => null,
|
|
350
|
+
useMachineLabel: () => null,
|
|
348
351
|
useChatPlace: () => NOWHERE,
|
|
349
352
|
useContextSources: () => NO_SOURCES,
|
|
350
353
|
useHidesFolder: () => false,
|