@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"types": "./dist/agents/accounts.d.ts",
|
|
35
35
|
"default": "./dist/agents/accounts.js"
|
|
36
36
|
},
|
|
37
|
+
"./agents/model-requirement": {
|
|
38
|
+
"source": "./src/agents/model-requirement.ts",
|
|
39
|
+
"types": "./dist/agents/model-requirement.d.ts",
|
|
40
|
+
"default": "./dist/agents/model-requirement.js"
|
|
41
|
+
},
|
|
37
42
|
"./agents/model-name": {
|
|
38
43
|
"source": "./src/agents/model-name.ts",
|
|
39
44
|
"types": "./dist/agents/model-name.d.ts",
|
|
@@ -104,6 +109,11 @@
|
|
|
104
109
|
"types": "./dist/chat/guards.d.ts",
|
|
105
110
|
"default": "./dist/chat/guards.js"
|
|
106
111
|
},
|
|
112
|
+
"./chat/logic/picker-entries": {
|
|
113
|
+
"source": "./src/chat/logic/picker-entries.ts",
|
|
114
|
+
"types": "./dist/chat/logic/picker-entries.d.ts",
|
|
115
|
+
"default": "./dist/chat/logic/picker-entries.js"
|
|
116
|
+
},
|
|
107
117
|
"./chat/logic/ansi": {
|
|
108
118
|
"source": "./src/chat/logic/ansi.ts",
|
|
109
119
|
"types": "./dist/chat/logic/ansi.d.ts",
|
|
@@ -219,6 +229,11 @@
|
|
|
219
229
|
"types": "./dist/chat/preferences.d.ts",
|
|
220
230
|
"default": "./dist/chat/preferences.js"
|
|
221
231
|
},
|
|
232
|
+
"./chat/prompt-hold": {
|
|
233
|
+
"source": "./src/chat/prompt-hold.ts",
|
|
234
|
+
"types": "./dist/chat/prompt-hold.d.ts",
|
|
235
|
+
"default": "./dist/chat/prompt-hold.js"
|
|
236
|
+
},
|
|
222
237
|
"./chat/quote": {
|
|
223
238
|
"source": "./src/chat/quote.ts",
|
|
224
239
|
"types": "./dist/chat/quote.d.ts",
|
|
@@ -494,6 +509,11 @@
|
|
|
494
509
|
"types": "./dist/chat/ui/composer/keys.d.ts",
|
|
495
510
|
"default": "./dist/chat/ui/composer/keys.js"
|
|
496
511
|
},
|
|
512
|
+
"./chat/ui/composer/menu-aria": {
|
|
513
|
+
"source": "./src/chat/ui/composer/menu-aria.ts",
|
|
514
|
+
"types": "./dist/chat/ui/composer/menu-aria.d.ts",
|
|
515
|
+
"default": "./dist/chat/ui/composer/menu-aria.js"
|
|
516
|
+
},
|
|
497
517
|
"./chat/ui/diff-theme": {
|
|
498
518
|
"source": "./src/chat/ui/diff-theme.ts",
|
|
499
519
|
"types": "./dist/chat/ui/diff-theme.d.ts",
|
|
@@ -1011,8 +1031,8 @@
|
|
|
1011
1031
|
"test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
|
|
1012
1032
|
},
|
|
1013
1033
|
"dependencies": {
|
|
1014
|
-
"@adecore/agent-contracts": "0.
|
|
1015
|
-
"@adecore/intelligent-ui": "0.
|
|
1034
|
+
"@adecore/agent-contracts": "0.22.0",
|
|
1035
|
+
"@adecore/intelligent-ui": "0.22.0",
|
|
1016
1036
|
"@codemirror/commands": "^6.11.1",
|
|
1017
1037
|
"@codemirror/language": "^6.12.4",
|
|
1018
1038
|
"@codemirror/state": "^6.7.6",
|
|
@@ -1033,7 +1053,7 @@
|
|
|
1033
1053
|
"zustand": "^5.0.15"
|
|
1034
1054
|
},
|
|
1035
1055
|
"peerDependencies": {
|
|
1036
|
-
"@adecore/ui": "^0.
|
|
1056
|
+
"@adecore/ui": "^0.22.0",
|
|
1037
1057
|
"i18next": "^26.4.2",
|
|
1038
1058
|
"react": "^19.3.0",
|
|
1039
1059
|
"react-dom": "^19.3.0",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
|
|
2
|
+
|
|
3
|
+
export interface ModelRequirement {
|
|
4
|
+
/* The CLI's name, as the provider list gives it. */
|
|
5
|
+
cli: string;
|
|
6
|
+
minVersion: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function partsOf(version: string, strict: boolean): number[] | null {
|
|
10
|
+
const found = (strict ? /^(\d+)\.(\d+)(?:\.(\d+))?$/ : /(\d+)\.(\d+)\.(\d+)/).exec(version);
|
|
11
|
+
return found === null ? null : [Number(found[1]), Number(found[2]), Number(found[3] ?? 0)];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Whether the model's reported name is this model: its slug, the slug with a suffix such as `[1m]`. */
|
|
15
|
+
function isReported(slug: string, reported: ReadonlySet<string>): boolean {
|
|
16
|
+
return [...reported].some((name) => name === slug || name.startsWith(`${slug}[`));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* What a model asks of the CLI that is too old for it, or null when nothing blocks it: no minimum is
|
|
21
|
+
* known, the CLI's version is not a readable x.y.z (so an unknown version invents no requirement), the
|
|
22
|
+
* CLI is new enough, or a chat of this provider already reported running the model.
|
|
23
|
+
*/
|
|
24
|
+
export function modelRequirementOf(model: ModelInfo, provider: ProviderInfo, reported: ReadonlySet<string>): ModelRequirement | null {
|
|
25
|
+
const minimum = model.minCliVersion === undefined ? null : partsOf(model.minCliVersion, true);
|
|
26
|
+
const installed = provider.version === null ? null : partsOf(provider.version, false);
|
|
27
|
+
if (minimum === null || installed === null || isReported(model.slug, reported)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
if (installed[i] !== minimum[i]) {
|
|
32
|
+
return (installed[i] ?? 0) > (minimum[i] ?? 0) ? null : { cli: provider.name, minVersion: model.minCliVersion ?? '' };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
package/src/chat/chat-client.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ChatAttachResult,
|
|
5
5
|
ChatBookmark,
|
|
6
6
|
ChatPreferencesPayload,
|
|
7
|
+
ChatQuoteSource,
|
|
7
8
|
ChatSkill,
|
|
8
9
|
ChatVisual,
|
|
9
10
|
ModelSelection,
|
|
@@ -35,6 +36,8 @@ export interface ChatSendExtras {
|
|
|
35
36
|
skills?: string[];
|
|
36
37
|
/* Chats of the project picked with `@`; only their ids travel, the agent reads them itself. */
|
|
37
38
|
chats?: string[];
|
|
39
|
+
/* The reply a quote at the head of the text was taken from. */
|
|
40
|
+
quoteSource?: ChatQuoteSource;
|
|
38
41
|
attachments?: ChatAttachmentUpload[];
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -170,9 +173,11 @@ export class ChatClient {
|
|
|
170
173
|
return this.transport.request('chat.send', {
|
|
171
174
|
chatId,
|
|
172
175
|
text,
|
|
176
|
+
delivery: extras.delivery,
|
|
173
177
|
mentions: extras.mentions,
|
|
174
178
|
skills: extras.skills,
|
|
175
179
|
chats: extras.chats,
|
|
180
|
+
quoteSource: extras.quoteSource,
|
|
176
181
|
attachments: extras.attachments
|
|
177
182
|
});
|
|
178
183
|
}
|
package/src/chat/drafts.ts
CHANGED
|
@@ -14,12 +14,22 @@ export interface ChatDraft {
|
|
|
14
14
|
attachments: ChatAttachmentUpload[];
|
|
15
15
|
/* A piece of an answer that goes above the text as a blockquote; empty for none. */
|
|
16
16
|
quote: string;
|
|
17
|
+
/* The reply the quote was taken from; null when the quote names none. */
|
|
18
|
+
quoteSource: string | null;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export const EMPTY_DRAFT: ChatDraft = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '' };
|
|
21
|
+
export const EMPTY_DRAFT: ChatDraft = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '', quoteSource: null };
|
|
20
22
|
|
|
21
23
|
// Older records only had text; the rest is filled in on read.
|
|
22
|
-
type DraftRecord = {
|
|
24
|
+
type DraftRecord = {
|
|
25
|
+
text: string;
|
|
26
|
+
mentions?: string[];
|
|
27
|
+
skills?: string[];
|
|
28
|
+
chats?: string[];
|
|
29
|
+
attachments?: ChatAttachmentUpload[];
|
|
30
|
+
quote?: string;
|
|
31
|
+
quoteSource?: string;
|
|
32
|
+
};
|
|
23
33
|
|
|
24
34
|
const storage = persistedJson<Record<string, DraftRecord>>(
|
|
25
35
|
storageKey,
|
|
@@ -63,7 +73,8 @@ export function readDraft(chatId: string): ChatDraft {
|
|
|
63
73
|
skills: record.skills ?? [],
|
|
64
74
|
chats: record.chats ?? [],
|
|
65
75
|
attachments: record.attachments ?? [],
|
|
66
|
-
quote: record.quote ?? ''
|
|
76
|
+
quote: record.quote ?? '',
|
|
77
|
+
quoteSource: record.quoteSource ?? null
|
|
67
78
|
}
|
|
68
79
|
: EMPTY_DRAFT;
|
|
69
80
|
}
|
|
@@ -82,13 +93,21 @@ export function writeDraft(chatId: string, draft: ChatDraft): void {
|
|
|
82
93
|
skills: draft.skills,
|
|
83
94
|
chats: draft.chats,
|
|
84
95
|
attachments: draft.attachments,
|
|
85
|
-
quote: draft.quote
|
|
96
|
+
quote: draft.quote,
|
|
97
|
+
...(draft.quoteSource === null ? {} : { quoteSource: draft.quoteSource })
|
|
86
98
|
};
|
|
87
99
|
if (storage.write(drafts)) {
|
|
88
100
|
return;
|
|
89
101
|
}
|
|
90
102
|
// A file can outgrow the storage quota; the text is the part worth keeping then.
|
|
91
|
-
drafts[chatId] = {
|
|
103
|
+
drafts[chatId] = {
|
|
104
|
+
text: draft.text,
|
|
105
|
+
mentions: draft.mentions,
|
|
106
|
+
skills: draft.skills,
|
|
107
|
+
chats: draft.chats,
|
|
108
|
+
quote: draft.quote,
|
|
109
|
+
...(draft.quoteSource === null ? {} : { quoteSource: draft.quoteSource })
|
|
110
|
+
};
|
|
92
111
|
storage.write(drafts);
|
|
93
112
|
}
|
|
94
113
|
|
|
@@ -108,7 +127,7 @@ function union(first: readonly string[], second: readonly string[]): string[] {
|
|
|
108
127
|
*/
|
|
109
128
|
export function takeBackIntoDraft(
|
|
110
129
|
current: ChatDraft,
|
|
111
|
-
taken: Omit<ChatDraft, 'quote'>
|
|
130
|
+
taken: Omit<ChatDraft, 'quote' | 'quoteSource'>
|
|
112
131
|
): { draft: ChatDraft; rejected: Array<{ name: string; reason: string }> } {
|
|
113
132
|
const checked = checkAttachmentLimits(
|
|
114
133
|
current.attachments.length,
|
|
@@ -122,7 +141,8 @@ export function takeBackIntoDraft(
|
|
|
122
141
|
skills: union(taken.skills, current.skills),
|
|
123
142
|
chats: union(taken.chats, current.chats),
|
|
124
143
|
attachments: [...checked.accepted.map((entry) => entry.upload), ...current.attachments],
|
|
125
|
-
quote: current.quote
|
|
144
|
+
quote: current.quote,
|
|
145
|
+
quoteSource: current.quoteSource
|
|
126
146
|
},
|
|
127
147
|
rejected: checked.rejected
|
|
128
148
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
|
|
2
|
+
|
|
3
|
+
/* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
|
|
4
|
+
export type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
|
|
5
|
+
|
|
6
|
+
function matches(model: ModelInfo, query: string): boolean {
|
|
7
|
+
return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
|
|
12
|
+
* only that model still finds the row that explains why it cannot be picked.
|
|
13
|
+
*/
|
|
14
|
+
export function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean): PickerEntry[] {
|
|
15
|
+
const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model' as const, provider: entry, model })));
|
|
16
|
+
if (query !== '') {
|
|
17
|
+
return rows.filter((row) => matches(row.model, query));
|
|
18
|
+
}
|
|
19
|
+
const live = rows.filter((row) => !row.model.legacy);
|
|
20
|
+
const legacy = rows.filter((row) => row.model.legacy);
|
|
21
|
+
if (legacy.length === 0) {
|
|
22
|
+
return live;
|
|
23
|
+
}
|
|
24
|
+
return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
|
|
25
|
+
}
|
|
@@ -52,7 +52,7 @@ export type TimelineRow =
|
|
|
52
52
|
| { kind: 'question'; id: string; item: ChatQuestionItem }
|
|
53
53
|
| { kind: 'note'; id: string; level: 'info' | 'warning' | 'error'; text: string; from?: string }
|
|
54
54
|
| { kind: 'compaction'; id: string; preTokens: number | null }
|
|
55
|
-
| { kind: 'changed-files'; id: string; turnId: string; tools: ChatToolItem[]; diff: ChatCheckpointDiff | null; checkpoint: boolean }
|
|
55
|
+
| { kind: 'changed-files'; id: string; turnId: string; tools: ChatToolItem[]; diff: ChatCheckpointDiff | null; checkpoint: boolean; baseMoved?: BaseMoved }
|
|
56
56
|
| { kind: 'turn-fold'; id: string; turn: ChatTurnItem; label: string; work: string[]; hiddenCount: number; expanded: boolean }
|
|
57
57
|
| { kind: 'forks'; id: string; turnId: string }
|
|
58
58
|
| { kind: 'working'; id: string; startedAt: number }
|
|
@@ -322,6 +322,12 @@ function rowsForItems(items: ChatItem[], options: TimelineOptions, children: Map
|
|
|
322
322
|
return rows;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/* The branch the turn started on was not the one it ended on, so its diff also holds what came in with the move. */
|
|
326
|
+
export interface BaseMoved {
|
|
327
|
+
from: string;
|
|
328
|
+
to: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
325
331
|
/* The card of a settled turn: the checkpoint diff when the host took one, else what the tool calls carry. */
|
|
326
332
|
function changedFilesRow(turn: ChatTurnItem, items: ChatItem[]): TimelineRow | null {
|
|
327
333
|
const edits = items.filter(
|
|
@@ -332,7 +338,16 @@ function changedFilesRow(turn: ChatTurnItem, items: ChatItem[]): TimelineRow | n
|
|
|
332
338
|
if (empty) {
|
|
333
339
|
return null;
|
|
334
340
|
}
|
|
335
|
-
|
|
341
|
+
const moved = turn.checkpointHead !== undefined && turn.checkpointHeadAfter !== undefined && turn.checkpointHead !== turn.checkpointHeadAfter;
|
|
342
|
+
return {
|
|
343
|
+
kind: 'changed-files',
|
|
344
|
+
id: `files-${turn.id}`,
|
|
345
|
+
turnId: turn.id,
|
|
346
|
+
tools: edits,
|
|
347
|
+
diff,
|
|
348
|
+
checkpoint: turn.checkpoint !== undefined,
|
|
349
|
+
...(moved ? { baseMoved: { from: turn.checkpointHead!, to: turn.checkpointHeadAfter! } } : {})
|
|
350
|
+
};
|
|
336
351
|
}
|
|
337
352
|
|
|
338
353
|
function lastAssistantRow(rows: TimelineRow[]): TimelineRow | null {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* How long a person must stop typing before a waiting prompt card takes the composer's place. */
|
|
2
|
+
export const PROMPT_HOLD_PAUSE_MS = 2000;
|
|
3
|
+
|
|
4
|
+
export interface HoldInput {
|
|
5
|
+
/* When the last change to the draft was made; null when none was since the hold last lifted. */
|
|
6
|
+
typedAt: number | null;
|
|
7
|
+
/* An IME composition is under way, which can run for any length without a change. */
|
|
8
|
+
composing: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type HoldRelease = 'pause' | 'send' | 'blur' | 'show';
|
|
12
|
+
|
|
13
|
+
/* A card waits while the person is composing, or typed less than the pause ago. */
|
|
14
|
+
export function holdsPrompt(input: HoldInput, now: number): boolean {
|
|
15
|
+
if (input.composing) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return input.typedAt !== null && now - input.typedAt < PROMPT_HOLD_PAUSE_MS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* The milliseconds until the hold lifts by itself; null when it holds until something else lifts it, or does not hold. */
|
|
22
|
+
export function holdLiftsIn(input: HoldInput, now: number): number | null {
|
|
23
|
+
if (input.composing || input.typedAt === null) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return Math.max(0, input.typedAt + PROMPT_HOLD_PAUSE_MS - now);
|
|
27
|
+
}
|
package/src/chat/quote.ts
CHANGED
|
@@ -24,3 +24,23 @@ export function withQuote(quote: string, message: string): string {
|
|
|
24
24
|
.join('\n');
|
|
25
25
|
return message === '' ? block : `${block}\n\n${message}`;
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* The inverse of `withQuote`: the quote a message opens with and what follows it. Null when the text
|
|
30
|
+
* does not open with a blockquote that ends on a blank line or the end of the text.
|
|
31
|
+
*/
|
|
32
|
+
export function splitQuote(text: string): { quote: string; rest: string } | null {
|
|
33
|
+
const lines = text.split('\n');
|
|
34
|
+
let end = 0;
|
|
35
|
+
while (end < lines.length && (lines[end] === '>' || lines[end]!.startsWith('> '))) {
|
|
36
|
+
end += 1;
|
|
37
|
+
}
|
|
38
|
+
if (end === 0 || (end < lines.length && lines[end] !== '')) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const quote = lines
|
|
42
|
+
.slice(0, end)
|
|
43
|
+
.map((line) => line.slice(2))
|
|
44
|
+
.join('\n');
|
|
45
|
+
return { quote, rest: lines.slice(end + 1).join('\n') };
|
|
46
|
+
}
|
package/src/chat/stash.ts
CHANGED
|
@@ -27,9 +27,17 @@ export interface StashedPrompt {
|
|
|
27
27
|
mentions: string[];
|
|
28
28
|
skills: string[];
|
|
29
29
|
attachments: StashedAttachment[];
|
|
30
|
+
/* A quote that knew its reply, kept apart from the text so it can lead back to it in the chat it came from. */
|
|
31
|
+
quote?: StashedQuote;
|
|
30
32
|
createdAt: number;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export interface StashedQuote {
|
|
36
|
+
text: string;
|
|
37
|
+
chatId: string;
|
|
38
|
+
itemId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function strings(value: unknown): string[] {
|
|
34
42
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
35
43
|
}
|
|
@@ -45,6 +53,18 @@ function attachments(value: unknown): StashedAttachment[] {
|
|
|
45
53
|
}));
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
function stashedQuote(value: unknown): StashedQuote | undefined {
|
|
57
|
+
if (!isRecord(value) || typeof value.text !== 'string' || typeof value.chatId !== 'string' || typeof value.itemId !== 'string') {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return { text: value.text, chatId: value.chatId, itemId: value.itemId };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stashedQuoteField(value: unknown): { quote?: StashedQuote } {
|
|
64
|
+
const quote = stashedQuote(value);
|
|
65
|
+
return quote === undefined ? {} : { quote };
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
/* Only the fields we know today, so anything an older or a broken write left behind falls away here. */
|
|
49
69
|
export function parseStash(raw: string | null): StashedPrompt[] {
|
|
50
70
|
if (raw === null) {
|
|
@@ -64,6 +84,7 @@ export function parseStash(raw: string | null): StashedPrompt[] {
|
|
|
64
84
|
mentions: strings(entry.mentions),
|
|
65
85
|
skills: strings(entry.skills),
|
|
66
86
|
attachments: attachments(entry.attachments),
|
|
87
|
+
...stashedQuoteField(entry.quote),
|
|
67
88
|
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0
|
|
68
89
|
}))
|
|
69
90
|
.slice(0, STASH_LIMIT);
|
|
@@ -77,14 +98,19 @@ export function withStashed(prompts: StashedPrompt[], entry: StashedPrompt): Sta
|
|
|
77
98
|
return [entry, ...prompts].slice(0, STASH_LIMIT);
|
|
78
99
|
}
|
|
79
100
|
|
|
80
|
-
/*
|
|
81
|
-
|
|
101
|
+
/*
|
|
102
|
+
* What a draft looks like on the shelf; null when there is nothing worth putting there. A quote that
|
|
103
|
+
* knows its reply stays apart, since it leads back to that reply in `chatId`; any other is folded into the text.
|
|
104
|
+
*/
|
|
105
|
+
export function stashedFrom(draft: ChatDraft, id: string, now: number, chatId: string): StashedPrompt | null {
|
|
82
106
|
if (isEmptyDraft(draft)) {
|
|
83
107
|
return null;
|
|
84
108
|
}
|
|
109
|
+
const kept = draft.quote !== '' && draft.quoteSource !== null;
|
|
85
110
|
return {
|
|
86
111
|
id,
|
|
87
|
-
text: withQuote(draft.quote, draft.text),
|
|
112
|
+
text: kept ? draft.text : withQuote(draft.quote, draft.text),
|
|
113
|
+
...(kept ? { quote: { text: draft.quote, chatId, itemId: draft.quoteSource! } } : {}),
|
|
88
114
|
mentions: [...draft.mentions],
|
|
89
115
|
skills: [...draft.skills],
|
|
90
116
|
attachments: draft.attachments.map((attachment) => ({ name: attachment.name, mime: attachment.mime, size: uploadBytes(attachment) })),
|
|
@@ -106,8 +132,8 @@ function write(prompts: StashedPrompt[]): void {
|
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
/* Puts a draft on the shelf; false when the composer held nothing to put there. */
|
|
109
|
-
export function stashDraft(draft: ChatDraft): boolean {
|
|
110
|
-
const entry = stashedFrom(draft, `stash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, Date.now());
|
|
135
|
+
export function stashDraft(draft: ChatDraft, chatId: string): boolean {
|
|
136
|
+
const entry = stashedFrom(draft, `stash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, Date.now(), chatId);
|
|
111
137
|
if (!entry) {
|
|
112
138
|
return false;
|
|
113
139
|
}
|
|
@@ -48,6 +48,16 @@ export function taskRowStateOf(item: ChatSubagentItem, task: SubagentTask | null
|
|
|
48
48
|
return word;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/* How far delegated work is: what settled as done, what still runs or waits, and how many of those wait on a person. */
|
|
52
|
+
export function progressOf(states: readonly TaskRowState[]): { done: number; total: number; waiting: number; active: number } {
|
|
53
|
+
return {
|
|
54
|
+
done: states.filter((state) => state === 'done').length,
|
|
55
|
+
total: states.length,
|
|
56
|
+
waiting: states.filter((state) => state === 'waiting').length,
|
|
57
|
+
active: states.filter((state) => state === 'running' || state === 'paused' || state === 'waiting').length
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
/*
|
|
52
62
|
* What the flyout over the composer lists: every sub-agent still at work, and the ones that settled
|
|
53
63
|
* since the last message, so a finished batch stays until the next message is sent. That includes an
|
|
@@ -106,11 +116,12 @@ export function entryTimeOf(item: ChatSubagentItem, task: SubagentTask | null, n
|
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
/*
|
|
109
|
-
* What the Stop of an active entry does: a task stops the node working on it, and a subagent
|
|
110
|
-
*
|
|
111
|
-
* turn may still wait on it, so stopping the turn
|
|
119
|
+
* What the Stop of an active entry does: a task stops the node working on it, and a subagent whose CLI
|
|
120
|
+
* named its task id (`native`) is ended on its own, turn or not. Any other subagent of the CLI's own is
|
|
121
|
+
* only marked stopped; while the chat's turn runs that turn may still wait on it, so stopping the turn
|
|
122
|
+
* (the composer's Stop) is the only offer then.
|
|
112
123
|
*/
|
|
113
|
-
export type SubagentStop = 'task' | 'mark';
|
|
124
|
+
export type SubagentStop = 'task' | 'native' | 'mark';
|
|
114
125
|
|
|
115
126
|
export function stopOf(item: ChatSubagentItem, turnRunning: boolean): SubagentStop | null {
|
|
116
127
|
if (item.status !== 'running') {
|
|
@@ -119,11 +130,14 @@ export function stopOf(item: ChatSubagentItem, turnRunning: boolean): SubagentSt
|
|
|
119
130
|
if (item.origin === 'ruimte') {
|
|
120
131
|
return item.childId === undefined ? null : 'task';
|
|
121
132
|
}
|
|
133
|
+
if (item.native?.agentId) {
|
|
134
|
+
return 'native';
|
|
135
|
+
}
|
|
122
136
|
return turnRunning ? null : 'mark';
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export function stopLabel(stop: SubagentStop): string {
|
|
126
|
-
return
|
|
140
|
+
return i18next.t(`agent-chat:subagents.stop.${stop}`);
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
/*
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// A page is a screen minus a little, so the line you were reading is still on it after the jump.
|
|
2
2
|
const PAGE_OVERLAP = 0.9;
|
|
3
3
|
|
|
4
|
+
// How long a jump asked before its thread registered stays up for that thread.
|
|
5
|
+
const PENDING_JUMP_TTL_MS = 10_000;
|
|
6
|
+
|
|
4
7
|
type Scroller = {
|
|
5
8
|
element: HTMLElement;
|
|
6
9
|
follow: (enabled: boolean) => void;
|
|
@@ -103,14 +106,17 @@ export function stepTimelineMessage(key: string, direction: -1 | 1): boolean {
|
|
|
103
106
|
*/
|
|
104
107
|
function waitingJumps<T>(): { register(key: string, jump: (target: T) => void): () => void; jump(key: string, target: T): boolean } {
|
|
105
108
|
const jumpers = new Map<string, (target: T) => void>();
|
|
106
|
-
const waiting = new Map<string, T>();
|
|
109
|
+
const waiting = new Map<string, { target: T; at: number }>();
|
|
107
110
|
return {
|
|
108
111
|
register(key, jump) {
|
|
109
112
|
jumpers.set(key, jump);
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
const pending = waiting.get(key);
|
|
114
|
+
if (pending !== undefined) {
|
|
112
115
|
waiting.delete(key);
|
|
113
|
-
jump
|
|
116
|
+
// A jump nobody picked up for long is not what the person wants any more.
|
|
117
|
+
if (Date.now() - pending.at <= PENDING_JUMP_TTL_MS) {
|
|
118
|
+
jump(pending.target);
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
return () => {
|
|
116
122
|
if (jumpers.get(key) === jump) {
|
|
@@ -121,7 +127,7 @@ function waitingJumps<T>(): { register(key: string, jump: (target: T) => void):
|
|
|
121
127
|
jump(key, target) {
|
|
122
128
|
const jump = jumpers.get(key);
|
|
123
129
|
if (jump === undefined) {
|
|
124
|
-
waiting.set(key, target);
|
|
130
|
+
waiting.set(key, { target, at: Date.now() });
|
|
125
131
|
return false;
|
|
126
132
|
}
|
|
127
133
|
jump(target);
|
|
@@ -130,15 +136,23 @@ function waitingJumps<T>(): { register(key: string, jump: (target: T) => void):
|
|
|
130
136
|
};
|
|
131
137
|
}
|
|
132
138
|
|
|
133
|
-
|
|
139
|
+
/* What a jump to an item may ask beyond scrolling to it. */
|
|
140
|
+
export interface ItemJumpOptions {
|
|
141
|
+
/* Lights the row up once it is in view. */
|
|
142
|
+
flash?: boolean;
|
|
143
|
+
/* Runs when the item is nowhere in the thread, after every earlier page has been tried. */
|
|
144
|
+
onMissing?(): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const itemJumps = waitingJumps<{ itemId: string; options: ItemJumpOptions }>();
|
|
134
148
|
|
|
135
|
-
export function registerItemJumper(key: string, jump: (itemId: string) => void): () => void {
|
|
136
|
-
return itemJumps.register(key, jump);
|
|
149
|
+
export function registerItemJumper(key: string, jump: (itemId: string, options: ItemJumpOptions) => void): () => void {
|
|
150
|
+
return itemJumps.register(key, ({ itemId, options }) => jump(itemId, options));
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
/* False when that chat has no thread on screen; the jump then waits for the next one that registers. */
|
|
140
|
-
export function jumpToTimelineItem(key: string, itemId: string): boolean {
|
|
141
|
-
return itemJumps.jump(key, itemId);
|
|
154
|
+
export function jumpToTimelineItem(key: string, itemId: string, options: ItemJumpOptions = {}): boolean {
|
|
155
|
+
return itemJumps.jump(key, { itemId, options });
|
|
142
156
|
}
|
|
143
157
|
|
|
144
158
|
/* Which part of a turn a jump lands on: the message that opened it, or the files it changed. */
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { useMemo, type ReactNode } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { Activity, Square, SquareTerminal } from 'lucide-react';
|
|
4
|
+
import { Activity, MessageCircleQuestionMark, Square, SquareTerminal } from 'lucide-react';
|
|
5
5
|
import type { ChatBackgroundTask, ChatSubagentItem } from '@adecore/agent-contracts';
|
|
6
6
|
import { backgroundCounts } from '../activity';
|
|
7
|
-
import { badgeCountOf, entryTimeOf, flyoutSubagents, statusWordOf, subagentTitle, summaryWordOf, taskIdOf } from '../subagent-list';
|
|
7
|
+
import { badgeCountOf, entryTimeOf, flyoutSubagents, progressOf, statusWordOf, subagentTitle, summaryWordOf, taskIdOf, taskRowStateOf } from '../subagent-list';
|
|
8
8
|
import { useSubagentSupport } from '../subagent-support';
|
|
9
9
|
import { canOpenSubagent, crumbOf, openFromMain, useSubagentTrail } from '../subagent-view';
|
|
10
10
|
import { SubagentStopButton } from './SubagentStopButton';
|
|
11
11
|
import { formatElapsedShort } from '@adecore/ui/format';
|
|
12
12
|
import { Icon, IconButton, Popover, Surface, useNow } from '@adecore/ui';
|
|
13
|
-
import { useChatRow } from '../../state/chats';
|
|
13
|
+
import { useChatRow, useChats } from '../../state/chats';
|
|
14
14
|
import { chatHost } from '../../host';
|
|
15
15
|
import { useChatScope } from '../../scope';
|
|
16
16
|
import { useChatActions } from '../actions';
|
|
@@ -28,8 +28,20 @@ export function ChatActivity({ chatId }: { chatId: string }) {
|
|
|
28
28
|
const order = useChatRow(chatId, (row) => row?.order) ?? NO_ITEMS;
|
|
29
29
|
const structure = useChatRow(chatId, (row) => row?.structure);
|
|
30
30
|
const subagents = useMemo(() => (structure === undefined ? [] : flyoutSubagents(order, structure)), [order, structure]);
|
|
31
|
-
const { id } = useChatScope();
|
|
31
|
+
const { id, keyOf } = useChatScope();
|
|
32
32
|
const tasks = chatHost().tasks.useTasks(id);
|
|
33
|
+
const statuses = useChats((s) => s.statusByKey);
|
|
34
|
+
const progress = useMemo(
|
|
35
|
+
() =>
|
|
36
|
+
progressOf(
|
|
37
|
+
subagents.map((item) => {
|
|
38
|
+
const taskId = taskIdOf(item);
|
|
39
|
+
const child = item.childId === undefined ? undefined : statuses[keyOf(item.childId)]?.info;
|
|
40
|
+
return taskRowStateOf(item, taskId === null ? null : (tasks?.[taskId] ?? null), child);
|
|
41
|
+
})
|
|
42
|
+
),
|
|
43
|
+
[subagents, tasks, statuses, keyOf]
|
|
44
|
+
);
|
|
33
45
|
const words = useMemo(
|
|
34
46
|
() =>
|
|
35
47
|
subagents.map((item) => {
|
|
@@ -44,13 +56,25 @@ export function ChatActivity({ chatId }: { chatId: string }) {
|
|
|
44
56
|
}
|
|
45
57
|
const { shells, monitors } = backgroundCounts(background);
|
|
46
58
|
const Slot = chatHost().SubagentSlot;
|
|
59
|
+
const counting = progress.active > 0 && progress.total > 1;
|
|
60
|
+
const label = counting
|
|
61
|
+
? [t('activity.progress', { done: progress.done, total: progress.total }), progress.waiting > 0 && t('activity.needsYou', { count: progress.waiting })]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join(', ')
|
|
64
|
+
: t('activity.subagents', { count: badgeCountOf(words) });
|
|
47
65
|
return (
|
|
48
66
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
49
67
|
{subagents.length > 0 &&
|
|
50
68
|
(Slot === null ? (
|
|
51
69
|
<Flyout
|
|
52
|
-
icon={
|
|
53
|
-
|
|
70
|
+
icon={
|
|
71
|
+
progress.waiting > 0 ? (
|
|
72
|
+
<Icon icon={MessageCircleQuestionMark} size={14} className="shrink-0 text-status-needs-you" />
|
|
73
|
+
) : (
|
|
74
|
+
<StatusIcon word={summaryWordOf(words)} />
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
label={label}
|
|
54
78
|
heading={t('activity.subagents', { count: subagents.length })}
|
|
55
79
|
>
|
|
56
80
|
<SubagentRows chatId={chatId} items={subagents} />
|
|
@@ -83,7 +107,7 @@ function Flyout({ icon, label, heading = label, children }: { icon: ReactNode; l
|
|
|
83
107
|
<Popover.Root>
|
|
84
108
|
<Surface
|
|
85
109
|
render={<Popover.Trigger />}
|
|
86
|
-
className="pointer-events-auto inline-flex h-7.5 items-center gap-1.5 rounded-full px-3 text-xs text-text-muted hover:text-text"
|
|
110
|
+
className="pointer-events-auto inline-flex h-7.5 items-center gap-1.5 rounded-full px-3 text-xs text-text-muted tabular-nums hover:text-text"
|
|
87
111
|
>
|
|
88
112
|
{icon}
|
|
89
113
|
{label}
|
|
@@ -148,11 +172,13 @@ function SubagentRows({ chatId, items }: { chatId: string; items: readonly ChatS
|
|
|
148
172
|
|
|
149
173
|
function SubagentEntry({ chatId, item, now }: { chatId: string; item: ChatSubagentItem; now: number }) {
|
|
150
174
|
const { t } = useTranslation('agent-chat');
|
|
151
|
-
const { id } = useChatScope();
|
|
175
|
+
const { id, keyOf } = useChatScope();
|
|
152
176
|
const refused = useSubagentSupport((s) => s.unsupported[id] === true);
|
|
153
177
|
const task = chatHost().tasks.useTask(id, taskIdOf(item));
|
|
154
178
|
const { show } = useSubagentTrail(chatId);
|
|
179
|
+
const child = useChats((s) => (item.childId === undefined ? undefined : s.statusByKey[keyOf(item.childId)]?.info));
|
|
155
180
|
const word = statusWordOf(item, task);
|
|
181
|
+
const state = taskRowStateOf(item, task, child);
|
|
156
182
|
return (
|
|
157
183
|
<ActivityRow
|
|
158
184
|
icon={<StatusIcon word={word} />}
|
|
@@ -160,7 +186,7 @@ function SubagentEntry({ chatId, item, now }: { chatId: string; item: ChatSubage
|
|
|
160
186
|
<>
|
|
161
187
|
{subagentTitle(item)}
|
|
162
188
|
{/* The icon says the state to the eye; a screen reader hears it with the title. */}
|
|
163
|
-
<span className="sr-only">, {t(`common.status.${word}`)}</span>
|
|
189
|
+
<span className="sr-only">, {state === 'waiting' ? t('rows.task.waiting') : t(`common.status.${word}`)}</span>
|
|
164
190
|
</>
|
|
165
191
|
}
|
|
166
192
|
time={entryTimeOf(item, task, now)}
|