@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
@@ -0,0 +1,6 @@
1
+ import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
+ export interface ModelRequirement {
3
+ cli: string;
4
+ minVersion: string;
5
+ }
6
+ export declare function modelRequirementOf(model: ModelInfo, provider: ProviderInfo, reported: ReadonlySet<string>): ModelRequirement | null;
@@ -0,0 +1,26 @@
1
+ function partsOf(version, strict) {
2
+ const found = (strict ? /^(\d+)\.(\d+)(?:\.(\d+))?$/ : /(\d+)\.(\d+)\.(\d+)/).exec(version);
3
+ return found === null ? null : [Number(found[1]), Number(found[2]), Number(found[3] ?? 0)];
4
+ }
5
+ /* Whether the model's reported name is this model: its slug, the slug with a suffix such as `[1m]`. */
6
+ function isReported(slug, reported) {
7
+ return [...reported].some((name) => name === slug || name.startsWith(`${slug}[`));
8
+ }
9
+ /*
10
+ * What a model asks of the CLI that is too old for it, or null when nothing blocks it: no minimum is
11
+ * known, the CLI's version is not a readable x.y.z (so an unknown version invents no requirement), the
12
+ * CLI is new enough, or a chat of this provider already reported running the model.
13
+ */
14
+ export function modelRequirementOf(model, provider, reported) {
15
+ const minimum = model.minCliVersion === undefined ? null : partsOf(model.minCliVersion, true);
16
+ const installed = provider.version === null ? null : partsOf(provider.version, false);
17
+ if (minimum === null || installed === null || isReported(model.slug, reported)) {
18
+ return null;
19
+ }
20
+ for (let i = 0; i < 3; i++) {
21
+ if (installed[i] !== minimum[i]) {
22
+ return (installed[i] ?? 0) > (minimum[i] ?? 0) ? null : { cli: provider.name, minVersion: model.minCliVersion ?? '' };
23
+ }
24
+ }
25
+ return null;
26
+ }
@@ -1,4 +1,4 @@
1
- import type { AgentKind, ChatAttachmentUpload, ChatAttachResult, ChatBookmark, ChatPreferencesPayload, ChatSkill, ChatVisual, ModelSelection, ProviderInfo, RuntimeMode } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ChatAttachmentUpload, ChatAttachResult, ChatBookmark, ChatPreferencesPayload, ChatQuoteSource, ChatSkill, ChatVisual, ModelSelection, ProviderInfo, RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { ChatSink } from '../state/chats.js';
3
3
  import { type ChatTransport } from '../transport.js';
4
4
  interface ChatOpenOptions {
@@ -14,6 +14,7 @@ export interface ChatSendExtras {
14
14
  mentions?: string[];
15
15
  skills?: string[];
16
16
  chats?: string[];
17
+ quoteSource?: ChatQuoteSource;
17
18
  attachments?: ChatAttachmentUpload[];
18
19
  }
19
20
  interface ProviderSink {
@@ -113,9 +113,11 @@ export class ChatClient {
113
113
  return this.transport.request('chat.send', {
114
114
  chatId,
115
115
  text,
116
+ delivery: extras.delivery,
116
117
  mentions: extras.mentions,
117
118
  skills: extras.skills,
118
119
  chats: extras.chats,
120
+ quoteSource: extras.quoteSource,
119
121
  attachments: extras.attachments
120
122
  });
121
123
  }
@@ -6,6 +6,7 @@ export interface ChatDraft {
6
6
  chats: string[];
7
7
  attachments: ChatAttachmentUpload[];
8
8
  quote: string;
9
+ quoteSource: string | null;
9
10
  }
10
11
  export declare const EMPTY_DRAFT: ChatDraft;
11
12
  export declare function isEmptyDraft(draft: ChatDraft): boolean;
@@ -16,7 +17,7 @@ export declare function useHasDraft(chatId: string): boolean;
16
17
  export declare function readDraft(chatId: string): ChatDraft;
17
18
  export declare function writeDraft(chatId: string, draft: ChatDraft): void;
18
19
  export declare function joinDraftText(current: string, added: string): string;
19
- export declare function takeBackIntoDraft(current: ChatDraft, taken: Omit<ChatDraft, 'quote'>): {
20
+ export declare function takeBackIntoDraft(current: ChatDraft, taken: Omit<ChatDraft, 'quote' | 'quoteSource'>): {
20
21
  draft: ChatDraft;
21
22
  rejected: Array<{
22
23
  name: string;
@@ -2,7 +2,7 @@ import { chatStorageKey, chatStorageLegacyKeys, createChatStore } from '../stora
2
2
  import { checkAttachmentLimits, uploadBytes } from './attachments.js';
3
3
  import { persistedJson } from './persisted-json.js';
4
4
  const storageKey = () => chatStorageKey('drafts');
5
- export const EMPTY_DRAFT = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '' };
5
+ export const EMPTY_DRAFT = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '', quoteSource: null };
6
6
  const storage = persistedJson(storageKey, (raw) => (raw ? JSON.parse(raw) : {}), {}, () => chatStorageLegacyKeys('drafts'));
7
7
  export function isEmptyDraft(draft) {
8
8
  return draft.text.trim() === '' && draft.attachments.length === 0 && draft.quote === '';
@@ -32,7 +32,8 @@ export function readDraft(chatId) {
32
32
  skills: record.skills ?? [],
33
33
  chats: record.chats ?? [],
34
34
  attachments: record.attachments ?? [],
35
- quote: record.quote ?? ''
35
+ quote: record.quote ?? '',
36
+ quoteSource: record.quoteSource ?? null
36
37
  }
37
38
  : EMPTY_DRAFT;
38
39
  }
@@ -50,13 +51,21 @@ export function writeDraft(chatId, draft) {
50
51
  skills: draft.skills,
51
52
  chats: draft.chats,
52
53
  attachments: draft.attachments,
53
- quote: draft.quote
54
+ quote: draft.quote,
55
+ ...(draft.quoteSource === null ? {} : { quoteSource: draft.quoteSource })
54
56
  };
55
57
  if (storage.write(drafts)) {
56
58
  return;
57
59
  }
58
60
  // A file can outgrow the storage quota; the text is the part worth keeping then.
59
- drafts[chatId] = { text: draft.text, mentions: draft.mentions, skills: draft.skills, chats: draft.chats, quote: draft.quote };
61
+ drafts[chatId] = {
62
+ text: draft.text,
63
+ mentions: draft.mentions,
64
+ skills: draft.skills,
65
+ chats: draft.chats,
66
+ quote: draft.quote,
67
+ ...(draft.quoteSource === null ? {} : { quoteSource: draft.quoteSource })
68
+ };
60
69
  storage.write(drafts);
61
70
  }
62
71
  /* Text handed to a draft from outside the composer goes under what was already typed, never over it. */
@@ -80,7 +89,8 @@ export function takeBackIntoDraft(current, taken) {
80
89
  skills: union(taken.skills, current.skills),
81
90
  chats: union(taken.chats, current.chats),
82
91
  attachments: [...checked.accepted.map((entry) => entry.upload), ...current.attachments],
83
- quote: current.quote
92
+ quote: current.quote,
93
+ quoteSource: current.quoteSource
84
94
  },
85
95
  rejected: checked.rejected
86
96
  };
@@ -0,0 +1,10 @@
1
+ import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
+ export type PickerEntry = {
3
+ kind: 'model';
4
+ provider: ProviderInfo;
5
+ model: ModelInfo;
6
+ } | {
7
+ kind: 'legacy';
8
+ count: number;
9
+ };
10
+ export declare function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean): PickerEntry[];
@@ -0,0 +1,19 @@
1
+ function matches(model, query) {
2
+ return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
3
+ }
4
+ /*
5
+ * The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
6
+ * only that model still finds the row that explains why it cannot be picked.
7
+ */
8
+ export function pickerEntriesOf(providers, query, legacyOpen) {
9
+ const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model', provider: entry, model })));
10
+ if (query !== '') {
11
+ return rows.filter((row) => matches(row.model, query));
12
+ }
13
+ const live = rows.filter((row) => !row.model.legacy);
14
+ const legacy = rows.filter((row) => row.model.legacy);
15
+ if (legacy.length === 0) {
16
+ return live;
17
+ }
18
+ return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
19
+ }
@@ -74,6 +74,7 @@ export type TimelineRow = {
74
74
  tools: ChatToolItem[];
75
75
  diff: ChatCheckpointDiff | null;
76
76
  checkpoint: boolean;
77
+ baseMoved?: BaseMoved;
77
78
  } | {
78
79
  kind: 'turn-fold';
79
80
  id: string;
@@ -115,5 +116,9 @@ export declare function findSubagentBranch(rows: readonly TimelineRow[], toolUse
115
116
  index: number;
116
117
  branch: SubagentBranch;
117
118
  } | null;
119
+ export interface BaseMoved {
120
+ from: string;
121
+ to: string;
122
+ }
118
123
  export declare function deriveTimelineRows(items: ChatItem[], options: TimelineOptions): TimelineRow[];
119
124
  export {};
@@ -253,7 +253,16 @@ function changedFilesRow(turn, items) {
253
253
  if (empty) {
254
254
  return null;
255
255
  }
256
- return { kind: 'changed-files', id: `files-${turn.id}`, turnId: turn.id, tools: edits, diff, checkpoint: turn.checkpoint !== undefined };
256
+ const moved = turn.checkpointHead !== undefined && turn.checkpointHeadAfter !== undefined && turn.checkpointHead !== turn.checkpointHeadAfter;
257
+ return {
258
+ kind: 'changed-files',
259
+ id: `files-${turn.id}`,
260
+ turnId: turn.id,
261
+ tools: edits,
262
+ diff,
263
+ checkpoint: turn.checkpoint !== undefined,
264
+ ...(moved ? { baseMoved: { from: turn.checkpointHead, to: turn.checkpointHeadAfter } } : {})
265
+ };
257
266
  }
258
267
  function lastAssistantRow(rows) {
259
268
  for (let i = rows.length - 1; i >= 0; i--) {
@@ -0,0 +1,8 @@
1
+ export declare const PROMPT_HOLD_PAUSE_MS = 2000;
2
+ export interface HoldInput {
3
+ typedAt: number | null;
4
+ composing: boolean;
5
+ }
6
+ export type HoldRelease = 'pause' | 'send' | 'blur' | 'show';
7
+ export declare function holdsPrompt(input: HoldInput, now: number): boolean;
8
+ export declare function holdLiftsIn(input: HoldInput, now: number): number | null;
@@ -0,0 +1,16 @@
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
+ /* A card waits while the person is composing, or typed less than the pause ago. */
4
+ export function holdsPrompt(input, now) {
5
+ if (input.composing) {
6
+ return true;
7
+ }
8
+ return input.typedAt !== null && now - input.typedAt < PROMPT_HOLD_PAUSE_MS;
9
+ }
10
+ /* The milliseconds until the hold lifts by itself; null when it holds until something else lifts it, or does not hold. */
11
+ export function holdLiftsIn(input, now) {
12
+ if (input.composing || input.typedAt === null) {
13
+ return null;
14
+ }
15
+ return Math.max(0, input.typedAt + PROMPT_HOLD_PAUSE_MS - now);
16
+ }
@@ -1,2 +1,6 @@
1
1
  export declare function quoteOf(selected: string): string | null;
2
2
  export declare function withQuote(quote: string, message: string): string;
3
+ export declare function splitQuote(text: string): {
4
+ quote: string;
5
+ rest: string;
6
+ } | null;
@@ -23,3 +23,22 @@ export function withQuote(quote, message) {
23
23
  .join('\n');
24
24
  return message === '' ? block : `${block}\n\n${message}`;
25
25
  }
26
+ /*
27
+ * The inverse of `withQuote`: the quote a message opens with and what follows it. Null when the text
28
+ * does not open with a blockquote that ends on a blank line or the end of the text.
29
+ */
30
+ export function splitQuote(text) {
31
+ const lines = text.split('\n');
32
+ let end = 0;
33
+ while (end < lines.length && (lines[end] === '>' || lines[end].startsWith('> '))) {
34
+ end += 1;
35
+ }
36
+ if (end === 0 || (end < lines.length && lines[end] !== '')) {
37
+ return null;
38
+ }
39
+ const quote = lines
40
+ .slice(0, end)
41
+ .map((line) => line.slice(2))
42
+ .join('\n');
43
+ return { quote, rest: lines.slice(end + 1).join('\n') };
44
+ }
@@ -12,13 +12,19 @@ export interface StashedPrompt {
12
12
  mentions: string[];
13
13
  skills: string[];
14
14
  attachments: StashedAttachment[];
15
+ quote?: StashedQuote;
15
16
  createdAt: number;
16
17
  }
18
+ export interface StashedQuote {
19
+ text: string;
20
+ chatId: string;
21
+ itemId: string;
22
+ }
17
23
  export declare function parseStash(raw: string | null): StashedPrompt[];
18
24
  export declare function withStashed(prompts: StashedPrompt[], entry: StashedPrompt): StashedPrompt[];
19
- export declare function stashedFrom(draft: ChatDraft, id: string, now: number): StashedPrompt | null;
25
+ export declare function stashedFrom(draft: ChatDraft, id: string, now: number, chatId: string): StashedPrompt | null;
20
26
  export declare const useStash: import("zustand").UseBoundStore<import("zustand").StoreApi<{
21
27
  prompts: StashedPrompt[];
22
28
  }>>;
23
- export declare function stashDraft(draft: ChatDraft): boolean;
29
+ export declare function stashDraft(draft: ChatDraft, chatId: string): boolean;
24
30
  export declare function forgetStashed(id: string): void;
@@ -23,6 +23,16 @@ function attachments(value) {
23
23
  size: typeof entry.size === 'number' ? entry.size : 0
24
24
  }));
25
25
  }
26
+ function stashedQuote(value) {
27
+ if (!isRecord(value) || typeof value.text !== 'string' || typeof value.chatId !== 'string' || typeof value.itemId !== 'string') {
28
+ return undefined;
29
+ }
30
+ return { text: value.text, chatId: value.chatId, itemId: value.itemId };
31
+ }
32
+ function stashedQuoteField(value) {
33
+ const quote = stashedQuote(value);
34
+ return quote === undefined ? {} : { quote };
35
+ }
26
36
  /* Only the fields we know today, so anything an older or a broken write left behind falls away here. */
27
37
  export function parseStash(raw) {
28
38
  if (raw === null) {
@@ -42,6 +52,7 @@ export function parseStash(raw) {
42
52
  mentions: strings(entry.mentions),
43
53
  skills: strings(entry.skills),
44
54
  attachments: attachments(entry.attachments),
55
+ ...stashedQuoteField(entry.quote),
45
56
  createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0
46
57
  }))
47
58
  .slice(0, STASH_LIMIT);
@@ -54,14 +65,19 @@ export function parseStash(raw) {
54
65
  export function withStashed(prompts, entry) {
55
66
  return [entry, ...prompts].slice(0, STASH_LIMIT);
56
67
  }
57
- /* What a draft looks like on the shelf, its quote folded into the text; null when there is nothing worth putting there. */
58
- export function stashedFrom(draft, id, now) {
68
+ /*
69
+ * What a draft looks like on the shelf; null when there is nothing worth putting there. A quote that
70
+ * knows its reply stays apart, since it leads back to that reply in `chatId`; any other is folded into the text.
71
+ */
72
+ export function stashedFrom(draft, id, now, chatId) {
59
73
  if (isEmptyDraft(draft)) {
60
74
  return null;
61
75
  }
76
+ const kept = draft.quote !== '' && draft.quoteSource !== null;
62
77
  return {
63
78
  id,
64
- text: withQuote(draft.quote, draft.text),
79
+ text: kept ? draft.text : withQuote(draft.quote, draft.text),
80
+ ...(kept ? { quote: { text: draft.quote, chatId, itemId: draft.quoteSource } } : {}),
65
81
  mentions: [...draft.mentions],
66
82
  skills: [...draft.skills],
67
83
  attachments: draft.attachments.map((attachment) => ({ name: attachment.name, mime: attachment.mime, size: uploadBytes(attachment) })),
@@ -76,8 +92,8 @@ function write(prompts) {
76
92
  storage.write(prompts);
77
93
  }
78
94
  /* Puts a draft on the shelf; false when the composer held nothing to put there. */
79
- export function stashDraft(draft) {
80
- const entry = stashedFrom(draft, `stash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, Date.now());
95
+ export function stashDraft(draft, chatId) {
96
+ const entry = stashedFrom(draft, `stash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, Date.now(), chatId);
81
97
  if (!entry) {
82
98
  return false;
83
99
  }
@@ -7,11 +7,17 @@ export declare function taskIdOf(item: ChatSubagentItem): string | null;
7
7
  export declare function statusWordOf(item: ChatSubagentItem, task: SubagentTask | null): StatusWord;
8
8
  export type TaskRowState = StatusWord | 'waiting';
9
9
  export declare function taskRowStateOf(item: ChatSubagentItem, task: SubagentTask | null, child: ChatInfo | undefined): TaskRowState;
10
+ export declare function progressOf(states: readonly TaskRowState[]): {
11
+ done: number;
12
+ total: number;
13
+ waiting: number;
14
+ active: number;
15
+ };
10
16
  export declare function flyoutSubagents(order: readonly string[], structure: Readonly<Record<string, ChatItem>>): ChatSubagentItem[];
11
17
  export declare function summaryWordOf(words: readonly StatusWord[]): StatusWord;
12
18
  export declare function badgeCountOf(words: readonly StatusWord[]): number;
13
19
  export declare function entryTimeOf(item: ChatSubagentItem, task: SubagentTask | null, now: number): string;
14
- export type SubagentStop = 'task' | 'mark';
20
+ export type SubagentStop = 'task' | 'native' | 'mark';
15
21
  export declare function stopOf(item: ChatSubagentItem, turnRunning: boolean): SubagentStop | null;
16
22
  export declare function stopLabel(stop: SubagentStop): string;
17
23
  export type ComposerStop = 'turn' | 'turn-and-subagents';
@@ -36,6 +36,15 @@ export function taskRowStateOf(item, task, child) {
36
36
  }
37
37
  return word;
38
38
  }
39
+ /* How far delegated work is: what settled as done, what still runs or waits, and how many of those wait on a person. */
40
+ export function progressOf(states) {
41
+ return {
42
+ done: states.filter((state) => state === 'done').length,
43
+ total: states.length,
44
+ waiting: states.filter((state) => state === 'waiting').length,
45
+ active: states.filter((state) => state === 'running' || state === 'paused' || state === 'waiting').length
46
+ };
47
+ }
39
48
  /*
40
49
  * What the flyout over the composer lists: every sub-agent still at work, and the ones that settled
41
50
  * since the last message, so a finished batch stays until the next message is sent. That includes an
@@ -93,10 +102,13 @@ export function stopOf(item, turnRunning) {
93
102
  if (item.origin === 'ruimte') {
94
103
  return item.childId === undefined ? null : 'task';
95
104
  }
105
+ if (item.native?.agentId) {
106
+ return 'native';
107
+ }
96
108
  return turnRunning ? null : 'mark';
97
109
  }
98
110
  export function stopLabel(stop) {
99
- return stop === 'task' ? i18next.t('agent-chat:subagents.stop.task') : i18next.t('agent-chat:subagents.stop.mark');
111
+ return i18next.t(`agent-chat:subagents.stop.${stop}`);
100
112
  }
101
113
  export function composerStopOf(shiftKey) {
102
114
  return shiftKey ? 'turn-and-subagents' : 'turn';
@@ -11,8 +11,12 @@ export declare function scrollTimelineToEnd(chatId: string, behavior?: ScrollBeh
11
11
  export declare function pageTimeline(chatId: string, direction: -1 | 1): boolean;
12
12
  export declare function registerMessageStepper(key: string, step: (direction: -1 | 1) => boolean): () => void;
13
13
  export declare function stepTimelineMessage(key: string, direction: -1 | 1): boolean;
14
- export declare function registerItemJumper(key: string, jump: (itemId: string) => void): () => void;
15
- export declare function jumpToTimelineItem(key: string, itemId: string): boolean;
14
+ export interface ItemJumpOptions {
15
+ flash?: boolean;
16
+ onMissing?(): void;
17
+ }
18
+ export declare function registerItemJumper(key: string, jump: (itemId: string, options: ItemJumpOptions) => void): () => void;
19
+ export declare function jumpToTimelineItem(key: string, itemId: string, options?: ItemJumpOptions): boolean;
16
20
  export type TurnTarget = 'prompt' | 'changes';
17
21
  type TurnJump = (turnId: string, target: TurnTarget) => void;
18
22
  export declare function registerTurnJumper(key: string, jump: TurnJump): () => void;
@@ -1,5 +1,7 @@
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
+ // How long a jump asked before its thread registered stays up for that thread.
4
+ const PENDING_JUMP_TTL_MS = 10_000;
3
5
  const scrollers = new Map();
4
6
  /* The timeline owns following; the composer subscribes here to show its jump-to-end button. */
5
7
  const ends = new Map();
@@ -83,10 +85,13 @@ function waitingJumps() {
83
85
  return {
84
86
  register(key, jump) {
85
87
  jumpers.set(key, jump);
86
- if (waiting.has(key)) {
87
- const target = waiting.get(key);
88
+ const pending = waiting.get(key);
89
+ if (pending !== undefined) {
88
90
  waiting.delete(key);
89
- jump(target);
91
+ // A jump nobody picked up for long is not what the person wants any more.
92
+ if (Date.now() - pending.at <= PENDING_JUMP_TTL_MS) {
93
+ jump(pending.target);
94
+ }
90
95
  }
91
96
  return () => {
92
97
  if (jumpers.get(key) === jump) {
@@ -97,7 +102,7 @@ function waitingJumps() {
97
102
  jump(key, target) {
98
103
  const jump = jumpers.get(key);
99
104
  if (jump === undefined) {
100
- waiting.set(key, target);
105
+ waiting.set(key, { target, at: Date.now() });
101
106
  return false;
102
107
  }
103
108
  jump(target);
@@ -107,11 +112,11 @@ function waitingJumps() {
107
112
  }
108
113
  const itemJumps = waitingJumps();
109
114
  export function registerItemJumper(key, jump) {
110
- return itemJumps.register(key, jump);
115
+ return itemJumps.register(key, ({ itemId, options }) => jump(itemId, options));
111
116
  }
112
117
  /* False when that chat has no thread on screen; the jump then waits for the next one that registers. */
113
- export function jumpToTimelineItem(key, itemId) {
114
- return itemJumps.jump(key, itemId);
118
+ export function jumpToTimelineItem(key, itemId, options = {}) {
119
+ return itemJumps.jump(key, { itemId, options });
115
120
  }
116
121
  const turnJumps = waitingJumps();
117
122
  export function registerTurnJumper(key, jump) {
@@ -2,15 +2,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useMemo } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Activity, Square, SquareTerminal } from 'lucide-react';
5
+ import { Activity, MessageCircleQuestionMark, Square, SquareTerminal } from 'lucide-react';
6
6
  import { backgroundCounts } from '../activity.js';
7
- import { badgeCountOf, entryTimeOf, flyoutSubagents, statusWordOf, subagentTitle, summaryWordOf, taskIdOf } from '../subagent-list.js';
7
+ import { badgeCountOf, entryTimeOf, flyoutSubagents, progressOf, statusWordOf, subagentTitle, summaryWordOf, taskIdOf, taskRowStateOf } from '../subagent-list.js';
8
8
  import { useSubagentSupport } from '../subagent-support.js';
9
9
  import { canOpenSubagent, crumbOf, openFromMain, useSubagentTrail } from '../subagent-view.js';
10
10
  import { SubagentStopButton } from './SubagentStopButton.js';
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.js';
13
+ import { useChatRow, useChats } from '../../state/chats.js';
14
14
  import { chatHost } from '../../host.js';
15
15
  import { useChatScope } from '../../scope.js';
16
16
  import { useChatActions } from '../actions.js';
@@ -24,8 +24,14 @@ export function ChatActivity({ chatId }) {
24
24
  const order = useChatRow(chatId, (row) => row?.order) ?? NO_ITEMS;
25
25
  const structure = useChatRow(chatId, (row) => row?.structure);
26
26
  const subagents = useMemo(() => (structure === undefined ? [] : flyoutSubagents(order, structure)), [order, structure]);
27
- const { id } = useChatScope();
27
+ const { id, keyOf } = useChatScope();
28
28
  const tasks = chatHost().tasks.useTasks(id);
29
+ const statuses = useChats((s) => s.statusByKey);
30
+ const progress = useMemo(() => progressOf(subagents.map((item) => {
31
+ const taskId = taskIdOf(item);
32
+ const child = item.childId === undefined ? undefined : statuses[keyOf(item.childId)]?.info;
33
+ return taskRowStateOf(item, taskId === null ? null : (tasks?.[taskId] ?? null), child);
34
+ })), [subagents, tasks, statuses, keyOf]);
29
35
  const words = useMemo(() => subagents.map((item) => {
30
36
  const taskId = taskIdOf(item);
31
37
  return statusWordOf(item, taskId === null ? null : (tasks?.[taskId] ?? null));
@@ -36,8 +42,14 @@ export function ChatActivity({ chatId }) {
36
42
  }
37
43
  const { shells, monitors } = backgroundCounts(background);
38
44
  const Slot = chatHost().SubagentSlot;
45
+ const counting = progress.active > 0 && progress.total > 1;
46
+ const label = counting
47
+ ? [t('activity.progress', { done: progress.done, total: progress.total }), progress.waiting > 0 && t('activity.needsYou', { count: progress.waiting })]
48
+ .filter(Boolean)
49
+ .join(', ')
50
+ : t('activity.subagents', { count: badgeCountOf(words) });
39
51
  return (_jsxs("div", { className: "mb-3 flex flex-wrap items-center gap-2", children: [subagents.length > 0 &&
40
- (Slot === null ? (_jsx(Flyout, { icon: _jsx(StatusIcon, { word: summaryWordOf(words) }), label: t('activity.subagents', { count: badgeCountOf(words) }), heading: t('activity.subagents', { count: subagents.length }), children: _jsx(SubagentRows, { chatId: chatId, items: subagents }) })) : (_jsx(Slot, { chatId: chatId, items: subagents }))), background.length > 0 && (_jsx(Flyout, { icon: _jsx(Icon, { icon: shells > 0 ? SquareTerminal : Activity, size: 14 }), label: [shells > 0 && t('activity.shells', { count: shells }), monitors > 0 && t('activity.monitors', { count: monitors })]
52
+ (Slot === null ? (_jsx(Flyout, { icon: progress.waiting > 0 ? (_jsx(Icon, { icon: MessageCircleQuestionMark, size: 14, className: "shrink-0 text-status-needs-you" })) : (_jsx(StatusIcon, { word: summaryWordOf(words) })), label: label, heading: t('activity.subagents', { count: subagents.length }), children: _jsx(SubagentRows, { chatId: chatId, items: subagents }) })) : (_jsx(Slot, { chatId: chatId, items: subagents }))), background.length > 0 && (_jsx(Flyout, { icon: _jsx(Icon, { icon: shells > 0 ? SquareTerminal : Activity, size: 14 }), label: [shells > 0 && t('activity.shells', { count: shells }), monitors > 0 && t('activity.monitors', { count: monitors })]
41
53
  .filter(Boolean)
42
54
  .join(' · '), children: _jsx(BackgroundTaskRows, { chatId: chatId, tasks: background }) }))] }));
43
55
  }
@@ -47,7 +59,7 @@ export function StatusIcon({ word }) {
47
59
  }
48
60
  /* A chip that opens a list over it, headed by what the chip says unless the list counts more than the chip does. */
49
61
  function Flyout({ icon, label, heading = label, children }) {
50
- return (_jsxs(Popover.Root, { children: [_jsxs(Surface, { render: _jsx(Popover.Trigger, {}), 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", children: [icon, label] }), _jsxs(Popover.Popup, { side: "top", className: "w-80", children: [_jsx("div", { className: "px-2.5 pt-1.5 pb-1 text-xs text-text-faint", children: heading }), children] })] }));
62
+ return (_jsxs(Popover.Root, { children: [_jsxs(Surface, { render: _jsx(Popover.Trigger, {}), 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", children: [icon, label] }), _jsxs(Popover.Popup, { side: "top", className: "w-80", children: [_jsx("div", { className: "px-2.5 pt-1.5 pb-1 text-xs text-text-faint", children: heading }), children] })] }));
51
63
  }
52
64
  /* One line of a flyout; with `onOpen` the line leads somewhere and closes the flyout on the way. */
53
65
  function ActivityRow({ icon, title, detail, time, onOpen, children }) {
@@ -62,12 +74,14 @@ function SubagentRows({ chatId, items }) {
62
74
  }
63
75
  function SubagentEntry({ chatId, item, now }) {
64
76
  const { t } = useTranslation('agent-chat');
65
- const { id } = useChatScope();
77
+ const { id, keyOf } = useChatScope();
66
78
  const refused = useSubagentSupport((s) => s.unsupported[id] === true);
67
79
  const task = chatHost().tasks.useTask(id, taskIdOf(item));
68
80
  const { show } = useSubagentTrail(chatId);
81
+ const child = useChats((s) => (item.childId === undefined ? undefined : s.statusByKey[keyOf(item.childId)]?.info));
69
82
  const word = statusWordOf(item, task);
70
- return (_jsx(ActivityRow, { icon: _jsx(StatusIcon, { word: word }), title: _jsxs(_Fragment, { children: [subagentTitle(item), _jsxs("span", { className: "sr-only", children: [", ", t(`common.status.${word}`)] })] }), time: entryTimeOf(item, task, now), onOpen: canOpenSubagent(item, refused) ? () => show(openFromMain(crumbOf(item))) : undefined, children: _jsx(SubagentStopButton, { chatId: chatId, item: item, className: "mr-1.5" }) }));
83
+ const state = taskRowStateOf(item, task, child);
84
+ return (_jsx(ActivityRow, { icon: _jsx(StatusIcon, { word: word }), title: _jsxs(_Fragment, { children: [subagentTitle(item), _jsxs("span", { className: "sr-only", children: [", ", state === 'waiting' ? t('rows.task.waiting') : t(`common.status.${word}`)] })] }), time: entryTimeOf(item, task, now), onOpen: canOpenSubagent(item, refused) ? () => show(openFromMain(crumbOf(item))) : undefined, children: _jsx(SubagentStopButton, { chatId: chatId, item: item, className: "mr-1.5" }) }));
71
85
  }
72
86
  /* Only drawn while the flyout is open, so the clock ticks for nobody otherwise. */
73
87
  function BackgroundTaskRows({ chatId, tasks }) {