@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.
Files changed (78) hide show
  1. package/dist/agents/model-requirement.d.ts +6 -0
  2. package/dist/agents/model-requirement.js +26 -0
  3. package/dist/chat/chat-client.d.ts +2 -1
  4. package/dist/chat/chat-client.js +2 -0
  5. package/dist/chat/drafts.d.ts +2 -1
  6. package/dist/chat/drafts.js +15 -5
  7. package/dist/chat/logic/picker-entries.d.ts +10 -0
  8. package/dist/chat/logic/picker-entries.js +19 -0
  9. package/dist/chat/logic/timeline.d.ts +5 -0
  10. package/dist/chat/logic/timeline.js +10 -1
  11. package/dist/chat/prompt-hold.d.ts +8 -0
  12. package/dist/chat/prompt-hold.js +16 -0
  13. package/dist/chat/quote.d.ts +4 -0
  14. package/dist/chat/quote.js +19 -0
  15. package/dist/chat/stash.d.ts +8 -2
  16. package/dist/chat/stash.js +21 -5
  17. package/dist/chat/subagent-list.d.ts +7 -1
  18. package/dist/chat/subagent-list.js +13 -1
  19. package/dist/chat/timeline-scroll.d.ts +6 -2
  20. package/dist/chat/timeline-scroll.js +12 -7
  21. package/dist/chat/ui/ChatActivity.js +22 -8
  22. package/dist/chat/ui/Composer.js +103 -19
  23. package/dist/chat/ui/ComposerInput.d.ts +3 -1
  24. package/dist/chat/ui/ComposerInput.js +13 -4
  25. package/dist/chat/ui/Pickers.js +26 -18
  26. package/dist/chat/ui/PromptComposer.d.ts +8 -1
  27. package/dist/chat/ui/PromptComposer.js +18 -6
  28. package/dist/chat/ui/QuoteButton.js +2 -2
  29. package/dist/chat/ui/SubagentControls.d.ts +4 -2
  30. package/dist/chat/ui/SubagentControls.js +12 -8
  31. package/dist/chat/ui/SubagentInfo.d.ts +1 -1
  32. package/dist/chat/ui/SubagentInfo.js +9 -15
  33. package/dist/chat/ui/SubagentStopButton.js +2 -1
  34. package/dist/chat/ui/Timeline.js +24 -3
  35. package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
  36. package/dist/chat/ui/composer/menu-aria.js +18 -0
  37. package/dist/chat/ui/quote-selection.d.ts +5 -2
  38. package/dist/chat/ui/quote-selection.js +3 -2
  39. package/dist/chat/ui/rows/MessageRows.js +24 -5
  40. package/dist/chat/ui/rows/Rows.js +1 -1
  41. package/dist/chat/ui/rows/WorkRows.d.ts +3 -1
  42. package/dist/chat/ui/rows/WorkRows.js +4 -4
  43. package/dist/host.d.ts +1 -0
  44. package/dist/host.js +1 -0
  45. package/dist/locales/en/agent-chat.json +30 -7
  46. package/dist/locales/nl/agent-chat.json +30 -7
  47. package/dist/state/chats.d.ts +2 -1
  48. package/dist/state/chats.js +19 -1
  49. package/package.json +24 -4
  50. package/src/agents/model-requirement.ts +36 -0
  51. package/src/chat/chat-client.ts +5 -0
  52. package/src/chat/drafts.ts +27 -7
  53. package/src/chat/logic/picker-entries.ts +25 -0
  54. package/src/chat/logic/timeline.ts +17 -2
  55. package/src/chat/prompt-hold.ts +27 -0
  56. package/src/chat/quote.ts +20 -0
  57. package/src/chat/stash.ts +31 -5
  58. package/src/chat/subagent-list.ts +19 -5
  59. package/src/chat/timeline-scroll.ts +24 -10
  60. package/src/chat/ui/ChatActivity.tsx +35 -9
  61. package/src/chat/ui/Composer.tsx +197 -52
  62. package/src/chat/ui/ComposerInput.tsx +18 -3
  63. package/src/chat/ui/Pickers.tsx +51 -30
  64. package/src/chat/ui/PromptComposer.tsx +37 -8
  65. package/src/chat/ui/QuoteButton.tsx +3 -2
  66. package/src/chat/ui/SubagentControls.tsx +22 -19
  67. package/src/chat/ui/SubagentInfo.tsx +24 -42
  68. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  69. package/src/chat/ui/Timeline.tsx +26 -4
  70. package/src/chat/ui/composer/menu-aria.ts +29 -0
  71. package/src/chat/ui/quote-selection.ts +6 -4
  72. package/src/chat/ui/rows/MessageRows.tsx +44 -5
  73. package/src/chat/ui/rows/Rows.tsx +3 -1
  74. package/src/chat/ui/rows/WorkRows.tsx +15 -1
  75. package/src/host.ts +3 -0
  76. package/src/locales/en/agent-chat.json +30 -7
  77. package/src/locales/nl/agent-chat.json +30 -7
  78. 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.21.2",
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.21.2",
1015
- "@adecore/intelligent-ui": "0.21.2",
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.21.2",
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
+ }
@@ -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
  }
@@ -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 = { text: string; mentions?: string[]; skills?: string[]; chats?: string[]; attachments?: ChatAttachmentUpload[]; quote?: string };
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] = { text: draft.text, mentions: draft.mentions, skills: draft.skills, chats: draft.chats, quote: draft.quote };
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
- return { kind: 'changed-files', id: `files-${turn.id}`, turnId: turn.id, tools: edits, diff, checkpoint: turn.checkpoint !== undefined };
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
- /* What a draft looks like on the shelf, its quote folded into the text; null when there is nothing worth putting there. */
81
- export function stashedFrom(draft: ChatDraft, id: string, now: number): StashedPrompt | null {
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 of the
110
- * CLI's own is only marked stopped, since no CLI stops one on its own. While the chat's turn runs that
111
- * turn may still wait on it, so stopping the turn (the composer's Stop) is the only offer then.
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 stop === 'task' ? i18next.t('agent-chat:subagents.stop.task') : i18next.t('agent-chat:subagents.stop.mark');
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
- if (waiting.has(key)) {
111
- const target = waiting.get(key)!;
113
+ const pending = waiting.get(key);
114
+ if (pending !== undefined) {
112
115
  waiting.delete(key);
113
- jump(target);
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
- const itemJumps = waitingJumps<string>();
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={<StatusIcon word={summaryWordOf(words)} />}
53
- label={t('activity.subagents', { count: badgeCountOf(words) })}
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)}