@adecore/agents-react 0.21.1 → 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/agents/status-look.js +2 -3
- package/dist/chat/chat-client.d.ts +3 -2
- package/dist/chat/chat-client.js +4 -2
- package/dist/chat/drafts.d.ts +2 -1
- package/dist/chat/drafts.js +21 -17
- package/dist/chat/logic/ansi.js +5 -1
- package/dist/chat/logic/fork.js +8 -9
- package/dist/chat/logic/limit.js +17 -14
- package/dist/chat/logic/picker-entries.d.ts +10 -0
- package/dist/chat/logic/picker-entries.js +19 -0
- package/dist/chat/logic/resume-compaction.js +2 -11
- package/dist/chat/logic/scrubber.js +9 -18
- package/dist/chat/logic/timeline.d.ts +5 -0
- package/dist/chat/logic/timeline.js +25 -11
- package/dist/chat/logic/tool-catalog.js +3 -4
- package/dist/chat/mentions.js +5 -6
- 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/runtime-modes.js +2 -4
- package/dist/chat/stash.d.ts +8 -2
- package/dist/chat/stash.js +22 -8
- package/dist/chat/subagent-list.d.ts +7 -1
- package/dist/chat/subagent-list.js +13 -1
- package/dist/chat/thumbnails.js +3 -4
- package/dist/chat/timeline-scroll.d.ts +6 -2
- package/dist/chat/timeline-scroll.js +45 -46
- package/dist/chat/ui/BookmarkSubmenu.js +1 -5
- package/dist/chat/ui/ChatActivity.js +22 -8
- package/dist/chat/ui/CodeBlock.js +2 -3
- package/dist/chat/ui/Composer.js +160 -88
- package/dist/chat/ui/ComposerInput.d.ts +3 -1
- package/dist/chat/ui/ComposerInput.js +13 -4
- package/dist/chat/ui/Markdown.js +11 -11
- package/dist/chat/ui/Pickers.js +31 -27
- package/dist/chat/ui/PromptComposer.d.ts +8 -1
- package/dist/chat/ui/PromptComposer.js +18 -6
- package/dist/chat/ui/QuoteButton.js +4 -5
- package/dist/chat/ui/RunSettings.js +8 -2
- package/dist/chat/ui/Scrubber.js +2 -3
- package/dist/chat/ui/SubagentControls.d.ts +4 -2
- package/dist/chat/ui/SubagentControls.js +13 -11
- 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 +39 -21
- package/dist/chat/ui/TimelineMenu.js +3 -9
- package/dist/chat/ui/UnifiedDiff.js +3 -5
- package/dist/chat/ui/VisualFrame.js +3 -4
- package/dist/chat/ui/composer/keys.js +19 -11
- 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/intelligent-ui/render-context.d.ts +1 -1
- 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 +26 -8
- package/dist/chat/ui/rows/Rows.js +1 -1
- package/dist/chat/ui/rows/WorkRows.d.ts +7 -4
- package/dist/chat/ui/rows/WorkRows.js +22 -17
- package/dist/chat/visual-viewport.d.ts +1 -1
- package/dist/chat/visual-viewport.js +9 -8
- 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/port-transport.js +4 -8
- package/dist/state/chats.d.ts +2 -1
- package/dist/state/chats.js +29 -15
- package/dist/state/usage.js +44 -49
- package/dist/storage.js +7 -7
- package/dist/theme.css +6 -14
- package/dist/usage/LimitsList.js +1 -2
- package/dist/usage/UsageBreakdown.js +1 -3
- package/dist/usage/UsageDialog.js +2 -4
- package/dist/usage/format.js +6 -6
- package/dist/usage/money.js +1 -5
- package/dist/usage/summary.js +2 -4
- package/package.json +24 -4
- package/src/agents/model-requirement.ts +36 -0
- package/src/agents/status-look.ts +2 -3
- package/src/chat/chat-client.ts +7 -3
- package/src/chat/drafts.ts +33 -21
- package/src/chat/logic/ansi.ts +5 -1
- package/src/chat/logic/fork.ts +8 -9
- package/src/chat/logic/limit.ts +17 -16
- package/src/chat/logic/picker-entries.ts +25 -0
- package/src/chat/logic/resume-compaction.ts +2 -11
- package/src/chat/logic/scrubber.ts +13 -17
- package/src/chat/logic/timeline.ts +39 -13
- package/src/chat/logic/tool-catalog.ts +7 -6
- package/src/chat/mentions.ts +5 -6
- package/src/chat/prompt-hold.ts +27 -0
- package/src/chat/quote.ts +20 -0
- package/src/chat/runtime-modes.ts +2 -4
- package/src/chat/stash.ts +32 -8
- package/src/chat/subagent-list.ts +19 -5
- package/src/chat/thumbnails.ts +3 -4
- package/src/chat/timeline-scroll.ts +58 -53
- package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
- package/src/chat/ui/ChatActivity.tsx +35 -9
- package/src/chat/ui/CodeBlock.tsx +2 -3
- package/src/chat/ui/Composer.tsx +263 -138
- package/src/chat/ui/ComposerInput.tsx +18 -3
- package/src/chat/ui/Markdown.tsx +13 -17
- package/src/chat/ui/Pickers.tsx +56 -39
- package/src/chat/ui/PromptComposer.tsx +37 -8
- package/src/chat/ui/QuoteButton.tsx +5 -5
- package/src/chat/ui/RunSettings.tsx +9 -2
- package/src/chat/ui/Scrubber.tsx +2 -3
- package/src/chat/ui/SubagentControls.tsx +23 -22
- package/src/chat/ui/SubagentInfo.tsx +24 -42
- package/src/chat/ui/SubagentStopButton.tsx +2 -1
- package/src/chat/ui/SubagentTimeline.tsx +1 -2
- package/src/chat/ui/Timeline.tsx +44 -24
- package/src/chat/ui/TimelineMenu.tsx +3 -9
- package/src/chat/ui/UnifiedDiff.tsx +3 -5
- package/src/chat/ui/VisualFrame.tsx +3 -4
- package/src/chat/ui/composer/keys.ts +21 -11
- package/src/chat/ui/composer/menu-aria.ts +29 -0
- package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
- package/src/chat/ui/quote-selection.ts +6 -4
- package/src/chat/ui/rehype-chips.ts +1 -1
- package/src/chat/ui/rehype-fade.ts +1 -1
- package/src/chat/ui/remark-html-as-text.ts +1 -1
- package/src/chat/ui/rows/MessageRows.tsx +47 -9
- package/src/chat/ui/rows/Rows.tsx +3 -1
- package/src/chat/ui/rows/WorkRows.tsx +101 -70
- package/src/chat/visual-viewport.ts +10 -8
- package/src/host.ts +4 -3
- package/src/locales/en/agent-chat.json +30 -7
- package/src/locales/nl/agent-chat.json +30 -7
- package/src/port-transport.ts +4 -8
- package/src/state/chats.ts +42 -16
- package/src/state/usage.ts +49 -60
- package/src/storage.ts +7 -7
- package/src/theme.css +6 -14
- package/src/usage/LimitsList.tsx +2 -4
- package/src/usage/UsageBreakdown.tsx +2 -5
- package/src/usage/UsageDialog.tsx +2 -4
- package/src/usage/format.ts +6 -6
- package/src/usage/money.ts +1 -5
- package/src/usage/summary.ts +2 -4
package/dist/chat/ui/Markdown.js
CHANGED
|
@@ -54,10 +54,8 @@ function languageOf(className) {
|
|
|
54
54
|
}
|
|
55
55
|
const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code';
|
|
56
56
|
/*
|
|
57
|
-
* A path an agent wrote down
|
|
58
|
-
*
|
|
59
|
-
* be clicked. `data-file-path` is what the thread's own right-click menu reads, so a link answers to
|
|
60
|
-
* both ways of opening it.
|
|
57
|
+
* A path an agent wrote down. It keeps the shape of the code chip it would otherwise be, so a sentence
|
|
58
|
+
* full of them does not shift; `data-file-path` is what the thread's right-click menu reads.
|
|
61
59
|
*/
|
|
62
60
|
function FileLink({ target, className, children }) {
|
|
63
61
|
const cwd = useFileLinkCwd();
|
|
@@ -72,8 +70,10 @@ function InlineCode({ text }) {
|
|
|
72
70
|
}
|
|
73
71
|
return (_jsx(FileLink, { target: target, className: `${INLINE_CODE} text-accent hover:underline`, children: text }));
|
|
74
72
|
}
|
|
75
|
-
/*
|
|
76
|
-
|
|
73
|
+
/*
|
|
74
|
+
* The path a link target spells, without the one scheme that still means a file on the host's machine.
|
|
75
|
+
* A href nobody encoded is left as it is, since decoding throws on a stray percent.
|
|
76
|
+
*/
|
|
77
77
|
function hrefPath(href) {
|
|
78
78
|
const bare = href.replace(/^file:\/\//, '');
|
|
79
79
|
try {
|
|
@@ -89,6 +89,9 @@ function MarkdownLink({ href, children }) {
|
|
|
89
89
|
if (target !== null) {
|
|
90
90
|
return (_jsx(FileLink, { target: target, className: "text-accent underline underline-offset-2", children: children }));
|
|
91
91
|
}
|
|
92
|
+
return _jsx(ExternalLink, { href: href, children: children });
|
|
93
|
+
}
|
|
94
|
+
function ExternalLink({ href, children }) {
|
|
92
95
|
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
93
96
|
}
|
|
94
97
|
function MarkdownCode({ className, children, plain = false }) {
|
|
@@ -118,9 +121,7 @@ const plainComponents = {
|
|
|
118
121
|
code({ className, children }) {
|
|
119
122
|
return (_jsx(MarkdownCode, { className: className, plain: true, children: children }));
|
|
120
123
|
},
|
|
121
|
-
a
|
|
122
|
-
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
123
|
-
}
|
|
124
|
+
a: ExternalLink
|
|
124
125
|
};
|
|
125
126
|
// A reply sits under the heading of its message, an `h3`, so its own headings start one level below
|
|
126
127
|
// that. Only `aria-level` moves. The tag stays, since the styles and a copy of the thread read it.
|
|
@@ -186,8 +187,7 @@ const PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
|
|
|
186
187
|
const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
|
|
187
188
|
const FADE_PLUGINS = [rehypeFadeWords];
|
|
188
189
|
const NO_PLUGINS = [];
|
|
189
|
-
/* One block of a reply. It renders no element of its own, so prose
|
|
190
|
-
direct children and keeps its first and last margins. */
|
|
190
|
+
/* One block of a reply. It renders no element of its own, so prose keeps its first and last margins. */
|
|
191
191
|
const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }) {
|
|
192
192
|
return (_jsx(CodeStreamingContext.Provider, { value: open, children: _jsx(ShellCodeBlockReplyContext.Provider, { value: reply ?? null, children: _jsx(ReactMarkdown, { remarkPlugins: REPLY_PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, components: replyComponents, children: text }) }) }));
|
|
193
193
|
});
|
package/dist/chat/ui/Pickers.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { Bookmark, ChevronDown, ChevronRight, Search, Trash2 } from 'lucide-react';
|
|
5
5
|
import { AgentIcon } from '../../agents/AgentIcon.js';
|
|
6
6
|
import { ProviderLogo } from '../../agents/ProviderLogo.js';
|
|
7
7
|
import { agentChipOf, modelName } from '../../agents/model-name.js';
|
|
8
|
+
import { modelRequirementOf } from '../../agents/model-requirement.js';
|
|
9
|
+
import { chatHost } from '../../host.js';
|
|
10
|
+
import { ChatScopeContext } from '../../scope.js';
|
|
11
|
+
import { useReportedModels } from '../../state/chats.js';
|
|
12
|
+
import { pickerEntriesOf } from '../logic/picker-entries.js';
|
|
8
13
|
import { forgetStashed, STASH_SHORTCUT, useStash } from '../stash.js';
|
|
9
14
|
import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
|
|
10
15
|
const triggerClass = 'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
|
|
11
16
|
const chipClass = 'flex h-6 min-w-0 shrink items-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2 text-xs text-text hover:bg-surface-active data-[popup-open]:bg-surface-active';
|
|
12
|
-
function matches(model, query) {
|
|
13
|
-
return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
|
|
14
|
-
}
|
|
15
17
|
/*
|
|
16
|
-
* Which model answers:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* another provider's model is also how a chat picks its provider. A chat bound to one CLI passes
|
|
20
|
-
* only that CLI, which drops the group headers and leaves its own catalog to choose from.
|
|
18
|
+
* Which model answers: a search field over one group per provider, with the legacy models behind an
|
|
19
|
+
* expander. Before the first message the list carries every installed CLI, so picking another
|
|
20
|
+
* provider's model is also how a chat picks its provider; with one CLI the group headers drop.
|
|
21
21
|
*/
|
|
22
22
|
export function ModelPicker({ providers, provider, selection, open, onOpenChange, onChange, kbd = '/model', side = 'top', trigger = 'plain', finalFocus, footer }) {
|
|
23
23
|
const { t } = useTranslation('agent-chat');
|
|
@@ -31,18 +31,13 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
|
|
|
31
31
|
const chip = agentChipOf(providers, provider, selection.model);
|
|
32
32
|
const grouped = providers.length > 1;
|
|
33
33
|
const trimmed = query.trim().toLowerCase();
|
|
34
|
-
const entries = useMemo(() =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (legacy.length === 0) {
|
|
42
|
-
return live;
|
|
43
|
-
}
|
|
44
|
-
return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
|
|
45
|
-
}, [providers, trimmed, legacyOpen]);
|
|
34
|
+
const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
|
|
35
|
+
const scope = useContext(ChatScopeContext);
|
|
36
|
+
const machine = chatHost().useMachineLabel(scope?.id ?? '');
|
|
37
|
+
const reported = useReportedModels(provider);
|
|
38
|
+
const requirementOf = (entry) => modelRequirementOf(entry.model, entry.provider, reported);
|
|
39
|
+
// A CLI that is too old blocks all its models; one way to its settings under the group says it once.
|
|
40
|
+
const blockedKinds = new Set(entries.flatMap((entry) => (entry.kind === 'model' && requirementOf(entry) !== null ? [entry.provider.kind] : [])));
|
|
46
41
|
/* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
|
|
47
42
|
const setOpen = (next) => {
|
|
48
43
|
if (!next) {
|
|
@@ -65,6 +60,9 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
|
|
|
65
60
|
inputRef.current?.focus();
|
|
66
61
|
return;
|
|
67
62
|
}
|
|
63
|
+
if (requirementOf(entry) !== null) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
68
66
|
onChange(entry.provider.kind, entry.model.slug);
|
|
69
67
|
setOpen(false);
|
|
70
68
|
};
|
|
@@ -91,15 +89,21 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
|
|
|
91
89
|
}
|
|
92
90
|
const previous = entries[i - 1];
|
|
93
91
|
const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
|
|
92
|
+
const requirement = requirementOf(entry);
|
|
93
|
+
const next = entries[i + 1];
|
|
94
|
+
const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
|
|
94
95
|
const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
|
|
95
|
-
return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), _jsxs("button", { role: "option", "aria-selected": chosen, "data-active": i === index, className:
|
|
96
|
+
return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), _jsxs("button", { role: "option", "aria-selected": chosen, "aria-disabled": requirement !== null || undefined, "data-active": i === index, className: `cursor-row flex w-full flex-col items-stretch gap-0.5 rounded-md px-2.5 py-1.5 text-left text-sm ${requirement === null ? 'text-text-muted' : 'text-text-faint'}`, onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }), entry.model.badge && (_jsx("span", { className: "rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent", children: entry.model.badge })), _jsx("span", { className: "grow" }), entry.model.legacy && _jsx("span", { className: "text-xs text-text-faint", children: t('pickers.model.legacy') })] }), requirement !== null && (_jsx("span", { className: "pl-6 text-xs text-text-faint", children: machine === null
|
|
97
|
+
? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
|
|
98
|
+
: t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine }) }))] }), lastOfGroup && blockedKinds.has(entry.provider.kind) && (_jsx("button", { type: "button", className: "cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted", onClick: () => {
|
|
99
|
+
setOpen(false);
|
|
100
|
+
chatHost().openSettings('providers');
|
|
101
|
+
}, children: t('pickers.model.providerSettings') }))] }, `${entry.provider.kind}/${entry.model.slug}`));
|
|
96
102
|
})] }), footer !== undefined && _jsx("div", { className: "border-t border-border-soft px-3 py-2 text-xs text-text-faint", children: footer })] })] }));
|
|
97
103
|
}
|
|
98
104
|
/*
|
|
99
|
-
* Prompts put aside with Cmd+S
|
|
100
|
-
*
|
|
101
|
-
* place. Restoring drops the text, the mentions and the skills into this composer; the files a
|
|
102
|
-
* draft held are named on the row but not kept, because their bytes never go to storage.
|
|
105
|
+
* Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
|
|
106
|
+
* to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
|
|
103
107
|
*/
|
|
104
108
|
export function StashPicker({ onRestore }) {
|
|
105
109
|
const { t } = useTranslation('agent-chat');
|
|
@@ -107,5 +111,5 @@ export function StashPicker({ onRestore }) {
|
|
|
107
111
|
if (prompts.length === 0) {
|
|
108
112
|
return null;
|
|
109
113
|
}
|
|
110
|
-
return (_jsxs(Popover.Root, { children: [_jsx(Tooltip, { label: t('pickers.stash.title'), kbd: STASH_SHORTCUT, children: _jsxs(Popover.Trigger, { className: triggerClass, children: [_jsx(Icon, { icon: Bookmark, size: 12 }), _jsx("span", { className: "tabular-nums", children: prompts.length })] }) }), _jsx(Popover.Popup, { variant: "picker", side: "top", className: "w-80", children: _jsx("div", { className: "max-h-72 overflow-auto p-1", children: prompts.map((prompt) => (_jsxs("div", { className: "group/stash flex items-start gap-1", children: [_jsxs(Popover.Close, { className: "flex min-w-0 grow flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover hover:text-text", onClick: () => onRestore(prompt), children: [_jsx("span", { className: "line-clamp-2 whitespace-pre-wrap", children: prompt.text || t('pickers.stash.noText') }), prompt.attachments.length > 0 && (_jsx("span", { className: "text-text-faint", children: t('pickers.stash.files', { count: prompt.attachments.length }) }))] }), _jsx(IconButton, { icon: Trash2, size: "xs", label: t('common.action.delete'), className: "mt-1 opacity-0 group-hover/stash:opacity-100 focus-visible:opacity-100", onClick: () => forgetStashed(prompt.id) })] }, prompt.id))) }) })] }));
|
|
114
|
+
return (_jsxs(Popover.Root, { children: [_jsx(Tooltip, { label: t('pickers.stash.title'), kbd: STASH_SHORTCUT, children: _jsxs(Popover.Trigger, { className: triggerClass, children: [_jsx(Icon, { icon: Bookmark, size: 12 }), _jsx("span", { className: "tabular-nums", children: prompts.length })] }) }), _jsx(Popover.Popup, { variant: "picker", side: "top", className: "w-80", children: _jsx("div", { className: "max-h-72 overflow-auto p-1", children: prompts.map((prompt) => (_jsxs("div", { className: "group/stash flex items-start gap-1", children: [_jsxs(Popover.Close, { className: "flex min-w-0 grow flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover hover:text-text", onClick: () => onRestore(prompt), children: [_jsx("span", { className: "line-clamp-2 whitespace-pre-wrap", children: prompt.text || prompt.quote?.text || t('pickers.stash.noText') }), prompt.attachments.length > 0 && (_jsx("span", { className: "text-text-faint", children: t('pickers.stash.files', { count: prompt.attachments.length }) }))] }), _jsx(IconButton, { icon: Trash2, size: "xs", label: t('common.action.delete'), className: "mt-1 opacity-0 group-hover/stash:opacity-100 focus-visible:opacity-100", onClick: () => forgetStashed(prompt.id) })] }, prompt.id))) }) })] }));
|
|
111
115
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
+
import type { HoldRelease } from '../prompt-hold.js';
|
|
2
3
|
import { type PendingPrompt } from '../../prompts/logic/prompts.js';
|
|
3
|
-
export
|
|
4
|
+
export interface PromptHold {
|
|
5
|
+
held: boolean;
|
|
6
|
+
releasedBy: HoldRelease | null;
|
|
7
|
+
release(reason: HoldRelease): void;
|
|
8
|
+
}
|
|
9
|
+
export declare function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, hold, children }: {
|
|
4
10
|
chatId: string;
|
|
5
11
|
pending: PendingPrompt[];
|
|
6
12
|
focused: boolean;
|
|
@@ -9,5 +15,6 @@ export declare function PromptComposer({ chatId, pending, focused, onShowElsewhe
|
|
|
9
15
|
hasDraft: boolean;
|
|
10
16
|
denyReason: boolean;
|
|
11
17
|
onAllAnswered?: () => void;
|
|
18
|
+
hold?: PromptHold;
|
|
12
19
|
children: ReactNode;
|
|
13
20
|
}): import("react").JSX.Element;
|
|
@@ -11,12 +11,12 @@ import { PromptView } from '../../prompts/ui/PromptView.js';
|
|
|
11
11
|
import { chatHost } from '../../host.js';
|
|
12
12
|
import { useChatScope } from '../../scope.js';
|
|
13
13
|
import { useChatActions } from '../actions.js';
|
|
14
|
-
import { Icon } from '@adecore/ui';
|
|
14
|
+
import { Button, Icon } from '@adecore/ui';
|
|
15
15
|
/* The one being read stays in front; otherwise blocking before optional, oldest first, as everywhere else. */
|
|
16
16
|
function pickSubject(waiting, activeId) {
|
|
17
17
|
return waiting.find((subject) => promptIdOf(subject) === activeId) ?? orderPrompts(waiting, isBlockingSubject, promptCreatedAt)[0] ?? null;
|
|
18
18
|
}
|
|
19
|
-
export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, children }) {
|
|
19
|
+
export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, hold, children }) {
|
|
20
20
|
const { t } = useTranslation('agent-chat');
|
|
21
21
|
const { id } = useChatScope();
|
|
22
22
|
const actions = useChatActions();
|
|
@@ -30,9 +30,21 @@ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disa
|
|
|
30
30
|
const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
|
|
31
31
|
const ref = useRef(null);
|
|
32
32
|
const { active, activeId: activeKey } = session;
|
|
33
|
-
const
|
|
33
|
+
const asksApproval = active?.kind === 'chat' ? active.item.kind === 'approval' : active?.prompt.asks === 'approval';
|
|
34
|
+
const held = !!active && (hold?.held ?? false);
|
|
35
|
+
const expanded = !!active && !held;
|
|
36
|
+
// The card that waited behind the draft, so a blur that released it is not mistaken for one released long before.
|
|
37
|
+
const heldKey = useRef(null);
|
|
34
38
|
const refocus = useFocusAfterAnswer(activeKey, ref, () => onAllAnswered?.());
|
|
35
39
|
useEffect(() => {
|
|
40
|
+
if (held) {
|
|
41
|
+
heldKey.current = activeKey;
|
|
42
|
+
}
|
|
43
|
+
}, [held, activeKey]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (hold?.releasedBy === 'blur' && heldKey.current === activeKey) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
36
48
|
if (focused && expanded && !elsewhere && !ref.current?.querySelector('.prompt-card')?.contains(document.activeElement)) {
|
|
37
49
|
// Keys meant for the draft may still be coming, and on a button the next Space would answer the card.
|
|
38
50
|
if (hasDraft) {
|
|
@@ -42,8 +54,8 @@ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disa
|
|
|
42
54
|
focusPromptStart(ref.current);
|
|
43
55
|
}
|
|
44
56
|
}
|
|
45
|
-
}, [activeKey, expanded, focused, elsewhere, hasDraft]);
|
|
46
|
-
return (_jsxs("div", { ref: ref, onPointerDownCapture: session.onPointerDownCapture, onClickCapture: session.onClickCapture, children: [active && elsewhere && (_jsxs("button", { type: "button", className: "flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text", onClick: onShowElsewhere, children: [_jsx(Icon, { icon:
|
|
57
|
+
}, [activeKey, expanded, focused, elsewhere, hasDraft, hold?.releasedBy]);
|
|
58
|
+
return (_jsxs("div", { ref: ref, onPointerDownCapture: session.onPointerDownCapture, onClickCapture: session.onClickCapture, children: [active && elsewhere && (_jsxs("button", { type: "button", className: "flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text", onClick: onShowElsewhere, children: [_jsx(Icon, { icon: asksApproval ? Hand : MessageCircleQuestionMark, size: 16, className: "shrink-0 text-status-needs-you" }), _jsx("span", { className: "grow", children: t('composer.waitingBelow') }), _jsx(Icon, { icon: ArrowDown, size: 16, className: "shrink-0" })] })), active && !elsewhere && (_jsx("div", { hidden: !expanded, children: _jsx(PromptView, { subject: active, draft: session.draftOf(promptIdOf(active)), onDraft: (draft) => session.setDraft(promptIdOf(active), draft), onAction: (action) => {
|
|
47
59
|
refocus.hold();
|
|
48
60
|
void session
|
|
49
61
|
.act(active, () => answerPrompt(active, action, actions))
|
|
@@ -52,5 +64,5 @@ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disa
|
|
|
52
64
|
refocus.release();
|
|
53
65
|
}
|
|
54
66
|
});
|
|
55
|
-
}, more: session.waiting.length - 1, hasDraft: hasDraft, denyReason: denyReason, disabled: disabled, sending: session.sending, error: session.errorOf(promptIdOf(active)) }, activeKey) })), _jsx("div", { hidden: expanded, children: children })] }));
|
|
67
|
+
}, more: session.waiting.length - 1, hasDraft: hasDraft, denyReason: denyReason, disabled: disabled, sending: session.sending, error: session.errorOf(promptIdOf(active)) }, activeKey) })), held && active && (_jsxs("div", { role: "status", className: "flex items-center gap-2 border-b border-border px-3.5 py-2.5", children: [_jsx(Icon, { icon: asksApproval ? Hand : MessageCircleQuestionMark, size: 16, className: "shrink-0 text-status-needs-you" }), _jsx("span", { className: "grow text-sm text-text-muted", children: t(asksApproval ? 'composer.held.approval' : 'composer.held.question') }), _jsx(Button, { size: "sm", onMouseDown: (e) => e.preventDefault(), onClick: () => hold?.release('show'), children: t('composer.held.show') })] })), _jsx("div", { hidden: expanded, children: children })] }));
|
|
56
68
|
}
|
|
@@ -13,9 +13,8 @@ function lastLineOf(range) {
|
|
|
13
13
|
return rects.at(-1) ?? null;
|
|
14
14
|
}
|
|
15
15
|
/*
|
|
16
|
-
* A button under a selection in an answer that hands it to the composer.
|
|
17
|
-
*
|
|
18
|
-
* pointer. It only shows once the pointer is up, so a drag never has it jump along.
|
|
16
|
+
* A button under a selection in an answer that hands it to the composer. It shows once the pointer is
|
|
17
|
+
* up: quoting on the selection alone grew the composer mid-drag and moved the thread under the pointer.
|
|
19
18
|
*/
|
|
20
19
|
export function QuoteButton({ thread, frame, taker }) {
|
|
21
20
|
const { t } = useTranslation('agent-chat');
|
|
@@ -36,7 +35,7 @@ export function QuoteButton({ thread, frame, taker }) {
|
|
|
36
35
|
const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
|
|
37
36
|
const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
|
|
38
37
|
const y = (line.bottom - bounds.top) / zoom + GAP_PX;
|
|
39
|
-
setOffer({ quote, x: Math.round(x), y: Math.round(y) });
|
|
38
|
+
setOffer({ quote: quote.text, itemId: quote.itemId, x: Math.round(x), y: Math.round(y) });
|
|
40
39
|
};
|
|
41
40
|
const press = () => {
|
|
42
41
|
pressed = true;
|
|
@@ -68,7 +67,7 @@ export function QuoteButton({ thread, frame, taker }) {
|
|
|
68
67
|
// Pressing a button moves the selection, and the selection is what it quotes.
|
|
69
68
|
onMouseDown: (e) => e.preventDefault(), onClick: () => {
|
|
70
69
|
document.getSelection()?.removeAllRanges();
|
|
71
|
-
taker.current?.(offer.quote);
|
|
70
|
+
taker.current?.(offer.quote, offer.itemId);
|
|
72
71
|
setOffer(null);
|
|
73
72
|
}, children: [_jsx(Icon, { icon: Quote, size: 12 }), t('composer.quote.add')] }));
|
|
74
73
|
}
|
|
@@ -47,6 +47,12 @@ function contextTone(fraction) {
|
|
|
47
47
|
function modelValue(provider, slug) {
|
|
48
48
|
return `${provider}/${slug}`;
|
|
49
49
|
}
|
|
50
|
+
/* Each provider's current or legacy models; a provider with none of them is left out. */
|
|
51
|
+
function modelGroupsOf(providers, legacy) {
|
|
52
|
+
return providers
|
|
53
|
+
.map((entry) => ({ entry, models: entry.models.filter((row) => (row.legacy === true) === legacy) }))
|
|
54
|
+
.filter((group) => group.models.length > 0);
|
|
55
|
+
}
|
|
50
56
|
/*
|
|
51
57
|
* Everything that decides how the next turn runs, behind one pill: the model, its own knobs and the
|
|
52
58
|
* permission mode. Model and effective permissions stay visible as the composer narrows.
|
|
@@ -68,8 +74,8 @@ export function RunSettings({ disabled = false, providers, provider, selection,
|
|
|
68
74
|
summary.push({ id: CONTEXT_OPTION, label: formatTokens(usage.contextWindow ?? 0), ring: true });
|
|
69
75
|
}
|
|
70
76
|
const grouped = providers.length > 1;
|
|
71
|
-
const live = providers
|
|
72
|
-
const legacy = providers
|
|
77
|
+
const live = modelGroupsOf(providers, false);
|
|
78
|
+
const legacy = modelGroupsOf(providers, true);
|
|
73
79
|
const chosenLegacy = owner?.models.find((row) => row.legacy && row.slug === selection.model);
|
|
74
80
|
const chosenModel = modelValue(provider, selection.model);
|
|
75
81
|
const shownMode = effectiveRuntimeMode ?? runtimeMode;
|
package/dist/chat/ui/Scrubber.js
CHANGED
|
@@ -34,9 +34,8 @@ const CARD_ACTIONS = {
|
|
|
34
34
|
const NOTHING_FOUND = [];
|
|
35
35
|
/*
|
|
36
36
|
* The strip at the left edge of a chat view: a tick per message of the person, per wake by tasks and
|
|
37
|
-
* per other message
|
|
38
|
-
*
|
|
39
|
-
* open, a short mark at the strip's right edge stands beside every tick with a hit under it.
|
|
37
|
+
* per other bookmarked message, the ones on screen bright. One hover card follows the pointer, so a
|
|
38
|
+
* thousand ticks are a thousand spans and not a thousand popups. An open find marks every tick it hits.
|
|
40
39
|
*/
|
|
41
40
|
export const Scrubber = memo(function Scrubber({ ticks, firstInView, lastInView, onPick, chat = null, found = NOTHING_FOUND, foundCurrent = null }) {
|
|
42
41
|
const { t } = useTranslation('agent-chat');
|
|
@@ -4,8 +4,10 @@ export declare function SubagentTitleCrumb({ chatId, className, children }: {
|
|
|
4
4
|
className?: string;
|
|
5
5
|
children: ReactNode;
|
|
6
6
|
}): import("react").JSX.Element;
|
|
7
|
-
export declare function SubagentBreadcrumb({ chatId,
|
|
7
|
+
export declare function SubagentBreadcrumb({ chatId, className }: {
|
|
8
8
|
chatId: string;
|
|
9
|
-
title?: string;
|
|
10
9
|
className?: string;
|
|
11
10
|
}): import("react").JSX.Element | null;
|
|
11
|
+
export declare function SubagentActions({ chatId }: {
|
|
12
|
+
chatId: string;
|
|
13
|
+
}): import("react").JSX.Element | null;
|
|
@@ -7,9 +7,7 @@ import { breadcrumbOf, MAIN_AGENT, trailTo, useOpenableSubagents, useSubagentTra
|
|
|
7
7
|
import { SubagentStopButton } from './SubagentStopButton.js';
|
|
8
8
|
import { Icon, IconButton } from '@adecore/ui';
|
|
9
9
|
const CRUMB_LINK = 'truncate text-text-muted hover:text-text';
|
|
10
|
-
/*
|
|
11
|
-
* The chat's own title where the bar draws it. While a sub-agent stands in the chat's place it is the first crumb of the way down, so pressing it goes back to the main agent.
|
|
12
|
-
*/
|
|
10
|
+
/* The chat's own title in the bar; while a sub-agent stands in the chat's place, pressing it goes back to the main agent. */
|
|
13
11
|
export function SubagentTitleCrumb({ chatId, className, children }) {
|
|
14
12
|
const { trail, show } = useSubagentTrail(chatId);
|
|
15
13
|
if (trail.length === 0) {
|
|
@@ -17,20 +15,24 @@ export function SubagentTitleCrumb({ chatId, className, children }) {
|
|
|
17
15
|
}
|
|
18
16
|
return (_jsx("button", { type: "button", className: clsx('flex min-w-0 items-center', className), onClick: () => show(MAIN_AGENT), children: children }));
|
|
19
17
|
}
|
|
20
|
-
/*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
/* 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. */
|
|
19
|
+
export function SubagentBreadcrumb({ chatId, className }) {
|
|
20
|
+
const { t } = useTranslation('agent-chat');
|
|
21
|
+
const { trail, show } = useSubagentTrail(chatId);
|
|
22
|
+
if (trail.length === 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return (_jsx("nav", { "aria-label": t('subagents.title'), className: clsx('flex min-w-0 items-center gap-1', className), children: breadcrumbOf(trail).map((step) => (_jsxs(Fragment, { children: [step.depth > 1 && _jsx(Icon, { icon: ChevronRight, size: 12, className: "shrink-0 text-text-faint" }), step.current ? (_jsx("span", { "aria-current": "page", className: "min-w-0 truncate text-text", children: step.label })) : (_jsx("button", { type: "button", className: clsx(CRUMB_LINK, 'min-w-0 shrink'), onClick: () => show(trailTo(trail, step.depth)), children: step.label }))] }, step.depth))) }));
|
|
26
|
+
}
|
|
27
|
+
/* Stops the sub-agent on screen where that is on offer, and closes it straight back to the main agent. */
|
|
28
|
+
export function SubagentActions({ chatId }) {
|
|
26
29
|
const { t } = useTranslation('agent-chat');
|
|
27
30
|
const { trail, show } = useSubagentTrail(chatId);
|
|
28
31
|
const subagents = useOpenableSubagents(chatId);
|
|
29
32
|
if (trail.length === 0) {
|
|
30
33
|
return null;
|
|
31
34
|
}
|
|
32
|
-
const steps = breadcrumbOf(trail);
|
|
33
35
|
// Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
|
|
34
36
|
const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0].toolUseId) : undefined;
|
|
35
|
-
return (_jsxs("
|
|
37
|
+
return (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [shown !== undefined && _jsx(SubagentStopButton, { chatId: chatId, item: shown, size: "sm" }), _jsx(IconButton, { icon: X, size: "sm", label: t('subagents.backToMain'), onClick: () => show(MAIN_AGENT) })] }));
|
|
36
38
|
}
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Fragment
|
|
3
|
-
import clsx from 'clsx';
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from 'react';
|
|
4
3
|
import { useTranslation } from 'react-i18next';
|
|
5
|
-
import { ChevronDown } from 'lucide-react';
|
|
6
4
|
import { entryTimeOf, statusWordOf, subagentFacts, taskIdOf } from '../subagent-list.js';
|
|
7
5
|
import { useSubagentItem } from '../subagent-view.js';
|
|
8
6
|
import { StatusIcon } from './ChatActivity.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { SubagentActions, SubagentBreadcrumb } from './SubagentControls.js';
|
|
8
|
+
import { useNow } from '@adecore/ui';
|
|
11
9
|
import { useModelName } from '../../agents/model-name.js';
|
|
12
10
|
import { useChatRow } from '../../state/chats.js';
|
|
13
11
|
import { chatHost } from '../../host.js';
|
|
14
12
|
import { useChatScope } from '../../scope.js';
|
|
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 }) {
|
|
20
18
|
const { t } = useTranslation('agent-chat');
|
|
@@ -24,11 +22,7 @@ export function SubagentInfo({ chatId, 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
|
-
|
|
30
|
-
}
|
|
31
|
-
const word = statusWordOf(item, task);
|
|
32
|
-
const facts = subagentFacts(item, item.model === undefined ? null : modelName);
|
|
33
|
-
return (_jsx("section", { "aria-label": t('subagents.info.label'), className: "shrink-0 border-b border-border px-4 py-2", children: _jsxs("div", { className: "chat-column-content flex flex-col gap-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2 text-xs text-text-faint", children: [_jsx(StatusIcon, { word: word }), _jsx("span", { className: "sr-only", children: t(`common.status.${word}`) }), _jsx("span", { className: "shrink-0 text-text-muted tabular-nums", children: entryTimeOf(item, task, now) }), facts.map((fact) => (_jsxs(Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "min-w-0 truncate", children: fact })] }, fact))), _jsx("span", { className: "grow" }), item.prompt && (_jsxs("button", { type: "button", className: "flex shrink-0 items-center gap-1.5 text-text-muted hover:text-text", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('transition-transform', open && 'rotate-180') }), open ? t('subagents.info.hideTask') : t('subagents.info.showTask')] }))] }), open && item.prompt && (_jsx("div", { className: "max-h-60 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-xs select-text", children: _jsx(Markdown, { text: item.prompt }) }))] }) }));
|
|
25
|
+
const word = item === null ? null : statusWordOf(item, task);
|
|
26
|
+
const facts = item === null ? [] : subagentFacts(item, item.model === undefined ? null : modelName);
|
|
27
|
+
return (_jsx("section", { "aria-label": t('subagents.info.label'), className: "shrink-0 border-b border-border px-4 py-1.5", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2 text-xs text-text-faint", children: [_jsx(SubagentBreadcrumb, { chatId: chatId, className: "max-w-1/3 shrink-0 font-medium" }), item !== null && word !== null && (_jsxs(_Fragment, { children: [_jsx(StatusIcon, { word: word }), _jsx("span", { className: "sr-only", children: t(`common.status.${word}`) }), _jsx("span", { className: "shrink-0 text-text-muted tabular-nums", children: entryTimeOf(item, task, now) })] })), facts.map((fact) => (_jsxs(Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "min-w-0 truncate", children: fact })] }, fact))), _jsx("span", { className: "grow" }), _jsx(SubagentActions, { chatId: chatId })] }) }));
|
|
34
28
|
}
|
|
@@ -19,7 +19,8 @@ export function SubagentStopButton({ chatId, item, size = 'xs', className }) {
|
|
|
19
19
|
}
|
|
20
20
|
const send = () => {
|
|
21
21
|
void actions.stopSubagent(chatId, item.toolUseId).catch((e) => {
|
|
22
|
-
const
|
|
22
|
+
const stale = e instanceof Error && 'code' in e && e.code === 'subagent-stale';
|
|
23
|
+
const description = stale ? t('subagents.stale') : e instanceof Error ? e.message : t('subagents.noAnswer');
|
|
23
24
|
chatHost().notify({ kind: 'error', title: t('subagents.stopFailed'), description });
|
|
24
25
|
});
|
|
25
26
|
};
|
package/dist/chat/ui/Timeline.js
CHANGED
|
@@ -37,6 +37,13 @@ import { AgentIcon } from '../../agents/AgentIcon.js';
|
|
|
37
37
|
import { useModelName } from '../../agents/model-name.js';
|
|
38
38
|
import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@adecore/ui';
|
|
39
39
|
const ESTIMATED_ROW_PX = 56;
|
|
40
|
+
// How long a jump waits for a thread that is still loading before it calls the item missing.
|
|
41
|
+
const JUMP_THREAD_WAIT_MS = 8000;
|
|
42
|
+
/* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
|
|
43
|
+
function showRowAtTop(virtualizer, followRef, index) {
|
|
44
|
+
followRef.current = false;
|
|
45
|
+
virtualizer.scrollToIndex(index, { align: 'start' });
|
|
46
|
+
}
|
|
40
47
|
/*
|
|
41
48
|
* A thread before its first message: which model answers, what the lines into this node let it read,
|
|
42
49
|
* and the folder it works in, so a first question can lean on all three.
|
|
@@ -191,7 +198,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
191
198
|
useEffect(() => registerTimeline(chatId, scrollRef.current, (enabled) => {
|
|
192
199
|
followRef.current = enabled;
|
|
193
200
|
}, () => coveredHeightRef.current), [chatId]);
|
|
194
|
-
//
|
|
201
|
+
// Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
|
|
195
202
|
// oxlint-disable-next-line react/incompatible-library
|
|
196
203
|
const virtualizer = useVirtualizer({
|
|
197
204
|
count: rows.length,
|
|
@@ -209,11 +216,9 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
209
216
|
});
|
|
210
217
|
const tail = lastReplyLength ?? rows.length;
|
|
211
218
|
/*
|
|
212
|
-
*
|
|
213
|
-
* that
|
|
214
|
-
*
|
|
215
|
-
* The scroller's own bottom is a fact rather than an estimate, and the total size changing is
|
|
216
|
-
* what says a row was measured again, so this runs for every one of those.
|
|
219
|
+
* Rows are measured as they render, so the end moves while it is drawn (a diff that highlights, an
|
|
220
|
+
* image that loads). `scrollToIndex` aims at where a row was when asked and fell short; the
|
|
221
|
+
* scroller's own bottom is a fact, and a new total size says a row was measured again.
|
|
217
222
|
*/
|
|
218
223
|
const totalSize = virtualizer.getTotalSize();
|
|
219
224
|
useLayoutEffect(() => {
|
|
@@ -259,8 +264,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
259
264
|
const paddingLeft = threadPaddingLeft(frame.width, showsScrubber);
|
|
260
265
|
/*
|
|
261
266
|
* Where each message starts, from the virtualizer's measurements (an estimate for a row it never
|
|
262
|
-
* drew), so reading the scroll position costs no layout.
|
|
263
|
-
* every scroll already.
|
|
267
|
+
* drew), so reading the scroll position costs no layout.
|
|
264
268
|
*/
|
|
265
269
|
const measurements = virtualizer.measurementsCache;
|
|
266
270
|
const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
|
|
@@ -314,8 +318,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
314
318
|
setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
|
|
315
319
|
return;
|
|
316
320
|
}
|
|
317
|
-
followRef.
|
|
318
|
-
virtualizer.scrollToIndex(tick.rowIndex, { align: 'start' });
|
|
321
|
+
showRowAtTop(virtualizer, followRef, tick.rowIndex);
|
|
319
322
|
};
|
|
320
323
|
useLayoutEffect(() => {
|
|
321
324
|
if (seeking === null) {
|
|
@@ -336,8 +339,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
336
339
|
else {
|
|
337
340
|
setSeeking(null);
|
|
338
341
|
if (jump.kind === 'row') {
|
|
339
|
-
followRef.
|
|
340
|
-
virtualizer.scrollToIndex(jump.index, { align: 'start' });
|
|
342
|
+
showRowAtTop(virtualizer, followRef, jump.index);
|
|
341
343
|
setLanding({ target: seeking.block, key: seeking.key });
|
|
342
344
|
}
|
|
343
345
|
}
|
|
@@ -346,11 +348,17 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
346
348
|
const index = rows.findIndex((row) => row.id === seeking.itemId);
|
|
347
349
|
if (index !== -1) {
|
|
348
350
|
setSeeking(null);
|
|
349
|
-
followRef
|
|
350
|
-
|
|
351
|
+
showRowAtTop(virtualizer, followRef, index);
|
|
352
|
+
if (seeking.options?.flash) {
|
|
353
|
+
useTimelineFlash.getState().flash(seeking.key, seeking.itemId, false);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// The thread has not loaded yet, so nothing says the item is missing; the effect below bounds the wait.
|
|
358
|
+
if (items === undefined) {
|
|
351
359
|
return;
|
|
352
360
|
}
|
|
353
|
-
const item = items
|
|
361
|
+
const item = items[seeking.itemId];
|
|
354
362
|
if (item === undefined && cursor !== null) {
|
|
355
363
|
loadEarlier();
|
|
356
364
|
return;
|
|
@@ -361,10 +369,22 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
361
369
|
return;
|
|
362
370
|
}
|
|
363
371
|
setSeeking(null);
|
|
372
|
+
seeking.options?.onMissing?.();
|
|
364
373
|
}, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
|
|
365
|
-
|
|
374
|
+
const waitingForThread = seeking !== null && items === undefined;
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
if (!waitingForThread) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const timer = window.setTimeout(() => {
|
|
380
|
+
setSeeking(null);
|
|
381
|
+
seeking?.options?.onMissing?.();
|
|
382
|
+
}, JUMP_THREAD_WAIT_MS);
|
|
383
|
+
return () => window.clearTimeout(timer);
|
|
384
|
+
}, [waitingForThread, seeking]);
|
|
385
|
+
useEffect(() => registerItemJumper(keyOf(chatId), (itemId, options) => {
|
|
366
386
|
setLanding(null);
|
|
367
|
-
setSeeking({ itemId, key: keyOf(chatId) });
|
|
387
|
+
setSeeking({ itemId, key: keyOf(chatId), options });
|
|
368
388
|
}), [keyOf, chatId]);
|
|
369
389
|
useLayoutEffect(() => {
|
|
370
390
|
if (!landing || landing.key !== keyOf(chatId)) {
|
|
@@ -399,8 +419,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
399
419
|
const found = changes !== -1 ? changes : rows.findIndex((row) => row.id === opening?.id || row.id === `start-${turnId}` || row.id === `fold-${turnId}`);
|
|
400
420
|
if (found !== -1) {
|
|
401
421
|
setRevealing(null);
|
|
402
|
-
followRef
|
|
403
|
-
virtualizer.scrollToIndex(found, { align: 'start' });
|
|
422
|
+
showRowAtTop(virtualizer, followRef, found);
|
|
404
423
|
useTimelineFlash.getState().flash(keyOf(chatId), rows[found].id, changes !== -1);
|
|
405
424
|
return;
|
|
406
425
|
}
|
|
@@ -452,8 +471,7 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
452
471
|
return;
|
|
453
472
|
}
|
|
454
473
|
subagents.add(found.branch.id);
|
|
455
|
-
followRef.
|
|
456
|
-
virtualizer.scrollToIndex(found.index, { align: 'start' });
|
|
474
|
+
showRowAtTop(virtualizer, followRef, found.index);
|
|
457
475
|
};
|
|
458
476
|
return (_jsx(FileLinkContext.Provider, { value: info?.cwd ?? null, children: _jsxs("div", { ref: frameRef, className: "relative flex min-h-0 grow flex-col", "data-file-cwd": info?.cwd ?? '', "data-file-scope-id": scope.id, children: [searching && chatFind.bar, _jsxs(ContextMenu.Root, { children: [_jsx("div", { ref: scrollRef,
|
|
459
477
|
// No CSS scroll-padding here: Chromium counts the sticky composer's caret as hidden behind
|
|
@@ -9,11 +9,7 @@ import { useChatScope } from '../../scope.js';
|
|
|
9
9
|
import { useChatRow } from '../../state/chats.js';
|
|
10
10
|
import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links.js';
|
|
11
11
|
import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
|
|
12
|
-
/*
|
|
13
|
-
* The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
|
|
14
|
-
* is text a person may want out of it, and a whole message, a code block or the markdown an answer
|
|
15
|
-
* was written in are each a different amount of that.
|
|
16
|
-
*/
|
|
12
|
+
/* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
|
|
17
13
|
export function TimelineMenuPopup({ target, thread, chatId = null }) {
|
|
18
14
|
const { t } = useTranslation('agent-chat');
|
|
19
15
|
const message = target.row === null ? null : messageTextOf(target.row);
|
|
@@ -36,10 +32,8 @@ export function TimelineMenuPopup({ target, thread, chatId = null }) {
|
|
|
36
32
|
return (_jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { disabled: target.selection === '', onClick: () => copyText(target.selection), children: [_jsx(Icon, { icon: Copy, size: 14 }), " ", t('common.action.copy'), " ", _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), message !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(message), children: [_jsx(Icon, { icon: MessageSquare, size: 14 }), " ", t('timeline.menu.copyMessage')] })), target.code !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(target.code ?? ''), children: [_jsx(Icon, { icon: Braces, size: 14 }), " ", t('timeline.menu.copyCode')] })), markdown !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(markdown), children: [_jsx(Icon, { icon: FileText, size: 14 }), " ", t('timeline.menu.copyMarkdown')] })), chatId !== null && turnId !== null && fork !== null && (_jsx(DisabledReason, { reason: forkBlocked, children: _jsxs(ContextMenu.Item, { disabled: forkBlocked !== null, onClick: () => fork(chatId, turnId), children: [_jsx(Icon, { icon: GitFork, size: 14 }), " ", t('timeline.menu.forkFromHere')] }) })), chatId !== null && markable !== null && _jsx(BookmarkMenuItems, { chatId: chatId, itemId: markable }), _jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: () => selectAllWithin(thread.current), children: [_jsx(Icon, { icon: Scan, size: 14 }), " ", t('common.action.selectAll')] }), target.path !== null && fileLinks !== null && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: openInPreview, children: [_jsx(Icon, { icon: Eye, size: 14 }), " ", t('timeline.menu.openInPreview')] })] }))] }));
|
|
37
33
|
}
|
|
38
34
|
/*
|
|
39
|
-
* Place a bookmark on a message, or rename and remove the one it has
|
|
40
|
-
*
|
|
41
|
-
* bookmark's line over the message and so has to be on screen; the strip scrolls there, the thread
|
|
42
|
-
* was clicked on it.
|
|
35
|
+
* Place a bookmark on a message, or rename and remove the one it has, from the thread and from the strip.
|
|
36
|
+
* `onName` runs when a row opens the name field, which sits over the message and so has to be on screen.
|
|
43
37
|
*/
|
|
44
38
|
export function BookmarkMenuItems({ chatId, itemId, onName }) {
|
|
45
39
|
const { t } = useTranslation('agent-chat');
|