@adecore/agents-react 0.36.0 → 0.37.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 (50) hide show
  1. package/dist/chat/chat-client.d.ts +5 -0
  2. package/dist/chat/chat-client.js +91 -20
  3. package/dist/chat/index.d.ts +1 -0
  4. package/dist/chat/index.js +2 -1
  5. package/dist/chat/logic/picker-entries.js +11 -6
  6. package/dist/chat/logic/scrubber.d.ts +4 -2
  7. package/dist/chat/logic/scrubber.js +3 -3
  8. package/dist/chat/pending-prompts.d.ts +15 -0
  9. package/dist/chat/pending-prompts.js +93 -0
  10. package/dist/chat/reading-width.js +6 -2
  11. package/dist/chat/ui/PromptComposer.d.ts +2 -2
  12. package/dist/chat/ui/PromptComposer.js +92 -98
  13. package/dist/chat/ui/SubagentTimeline.js +128 -182
  14. package/dist/chat/ui/Timeline.js +86 -60
  15. package/dist/chat/ui/code-lines.d.ts +5 -0
  16. package/dist/chat/ui/code-lines.js +37 -0
  17. package/dist/chat/ui/composer/use-pending-requests.d.ts +2 -2
  18. package/dist/chat/ui/composer/use-pending-requests.js +29 -37
  19. package/dist/chat/ui/highlighter.d.ts +1 -1
  20. package/dist/chat/ui/highlighter.js +4 -2
  21. package/dist/locales/en/agent-chat.d.ts +1 -0
  22. package/dist/locales/en/agent-chat.js +1 -0
  23. package/dist/locales/nl/agent-chat.d.ts +1 -0
  24. package/dist/locales/nl/agent-chat.js +1 -0
  25. package/dist/prompts/logic/subjects.d.ts +5 -0
  26. package/dist/prompts/ui/PromptCard.js +1 -1
  27. package/dist/prompts/ui/PromptView.js +104 -95
  28. package/dist/state/chats.d.ts +4 -0
  29. package/dist/state/chats.js +84 -48
  30. package/dist/theme.css +1 -1
  31. package/package.json +8 -8
  32. package/src/chat/chat-client.ts +92 -21
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/logic/picker-entries.ts +6 -4
  35. package/src/chat/logic/scrubber.ts +8 -6
  36. package/src/chat/pending-prompts.ts +108 -0
  37. package/src/chat/reading-width.ts +9 -2
  38. package/src/chat/ui/PromptComposer.tsx +10 -7
  39. package/src/chat/ui/SubagentTimeline.tsx +1 -24
  40. package/src/chat/ui/Timeline.tsx +66 -40
  41. package/src/chat/ui/code-lines.ts +37 -0
  42. package/src/chat/ui/composer/use-pending-requests.ts +13 -21
  43. package/src/chat/ui/highlighter.ts +4 -3
  44. package/src/locales/en/agent-chat.ts +1 -0
  45. package/src/locales/nl/agent-chat.ts +1 -0
  46. package/src/prompts/logic/subjects.ts +4 -1
  47. package/src/prompts/ui/PromptCard.tsx +1 -1
  48. package/src/prompts/ui/PromptView.tsx +7 -1
  49. package/src/state/chats.ts +63 -18
  50. package/src/theme.css +1 -1
@@ -27,10 +27,13 @@ export declare class ChatClient {
27
27
  private readonly mounted;
28
28
  private readonly unsubscribe;
29
29
  private readonly earlier;
30
+ private readonly observers;
30
31
  private readonly inspections;
31
32
  private preferences;
33
+ private checkpoints;
32
34
  constructor(transport: ChatTransport, sink: ChatSink, providers?: ProviderSink | null);
33
35
  inspect(chatId: string): Promise<ChatAttachResult>;
36
+ observe(chatId: string): () => void;
34
37
  open(chatId: string, options: ChatOpenOptions): Promise<boolean>;
35
38
  retarget(chatId: string, provider: AgentKind, selection: ModelSelection): Promise<void>;
36
39
  setPreferences(preferences: ChatPreferencesPayload): void;
@@ -53,6 +56,8 @@ export declare class ChatClient {
53
56
  isMounted(chatId: string): boolean;
54
57
  dispose(): void;
55
58
  private attach;
59
+ private sequence;
60
+ private rememberSequence;
56
61
  private sendPreferences;
57
62
  private onStatus;
58
63
  }
@@ -15,8 +15,10 @@ export class ChatClient {
15
15
  mounted = new MountedRegistry();
16
16
  unsubscribe = [];
17
17
  earlier = new Map();
18
+ observers = new Map();
18
19
  inspections = new Map();
19
20
  preferences = null;
21
+ checkpoints = new WeakMap();
20
22
  constructor(transport, sink, providers = null) {
21
23
  this.transport = transport;
22
24
  this.sink = sink;
@@ -26,10 +28,10 @@ export class ChatClient {
26
28
  event,
27
29
  seq
28
30
  }) => {
31
+ const previous = this.sequence(chatId);
29
32
  this.sink.apply(chatId, event);
30
- const entry = this.mounted.get(chatId);
31
- if (entry && seq !== undefined) {
32
- entry.seq = seq;
33
+ if (previous !== undefined || !this.sink.historyIdentity && this.mounted.get(chatId)?.attached) {
34
+ this.rememberSequence(chatId, seq);
33
35
  }
34
36
  }),
35
37
  // Every chat on the machine, attached or not, so a node waiting on a person says so on a view nobody has open.
@@ -68,6 +70,33 @@ export class ChatClient {
68
70
  this.inspections.set(chatId, read);
69
71
  return read;
70
72
  }
73
+ /* Shares a live attachment without creating a chat; a visible composer and several ancestors may hold it together. */
74
+ observe(chatId) {
75
+ this.observers.set(chatId, (this.observers.get(chatId) ?? 0) + 1);
76
+ if (!this.mounted.has(chatId)) {
77
+ this.mounted.set(chatId, {
78
+ attached: false,
79
+ observerOnly: true
80
+ });
81
+ void this.attach(chatId).catch(() => undefined);
82
+ }
83
+ let released = false;
84
+ return () => {
85
+ if (released) {
86
+ return;
87
+ }
88
+ released = true;
89
+ const remaining = (this.observers.get(chatId) ?? 1) - 1;
90
+ if (remaining > 0) {
91
+ this.observers.set(chatId, remaining);
92
+ return;
93
+ }
94
+ this.observers.delete(chatId);
95
+ if (this.mounted.get(chatId)?.observerOnly) {
96
+ void this.detach(chatId);
97
+ }
98
+ };
99
+ }
71
100
  /* Answers false when the transport is not connected; the chat opens once it is. */
72
101
  async open(chatId, options) {
73
102
  this.mounted.set(chatId, {
@@ -100,8 +129,10 @@ export class ChatClient {
100
129
  attached: _attached,
101
130
  account: _account,
102
131
  seq: _seq,
132
+ observerOnly: _observerOnly,
103
133
  ...options
104
134
  } = entry;
135
+ this.rememberSequence(chatId, undefined);
105
136
  this.mounted.delete(chatId);
106
137
  await this.transport.request('chat.kill', {
107
138
  chatId
@@ -127,6 +158,10 @@ export class ChatClient {
127
158
  }
128
159
  async detach(chatId) {
129
160
  const entry = this.mounted.get(chatId);
161
+ if (entry && this.observers.has(chatId)) {
162
+ entry.observerOnly = true;
163
+ return;
164
+ }
130
165
  this.mounted.delete(chatId);
131
166
  if (!entry?.attached) {
132
167
  return;
@@ -271,12 +306,16 @@ export class ChatClient {
271
306
  chatId,
272
307
  cursor,
273
308
  limit: HISTORY_PAGE
274
- }).then(page => this.sink.prepend(chatId, cursor, page)).catch(async e => {
309
+ }).then(page => {
310
+ const seq = this.sequence(chatId);
311
+ this.sink.prepend(chatId, cursor, page);
312
+ this.rememberSequence(chatId, seq);
313
+ }).catch(async e => {
275
314
  const entry = this.mounted.get(chatId);
276
315
  if (errorCode(e) !== 'history-expired' || !entry) {
277
316
  throw e;
278
317
  }
279
- delete entry.seq;
318
+ this.rememberSequence(chatId, undefined);
280
319
  await this.attach(chatId);
281
320
  }).finally(() => this.earlier.delete(chatId));
282
321
  this.earlier.set(chatId, read);
@@ -291,6 +330,8 @@ export class ChatClient {
291
330
  off();
292
331
  }
293
332
  this.unsubscribe.length = 0;
333
+ this.observers.clear();
334
+ this.checkpoints = new WeakMap();
294
335
  for (const chatId of [...this.mounted.keys()]) {
295
336
  void this.detach(chatId);
296
337
  }
@@ -305,20 +346,22 @@ export class ChatClient {
305
346
  if (!entry) {
306
347
  return;
307
348
  }
308
- await this.transport.request('chat.create', {
309
- chatId,
310
- provider: entry.provider,
311
- account: entry.account,
312
- cwd: entry.cwd,
313
- resume: entry.resume,
314
- selection: entry.selection,
315
- runtimeMode: entry.runtimeMode
316
- });
349
+ if (!entry.observerOnly) {
350
+ await this.transport.request('chat.create', {
351
+ chatId,
352
+ provider: entry.provider,
353
+ account: entry.account,
354
+ cwd: entry.cwd,
355
+ resume: entry.resume,
356
+ selection: entry.selection,
357
+ runtimeMode: entry.runtimeMode
358
+ });
359
+ }
317
360
  // Closed or opened again in the meantime: an opening that is still there attaches itself.
318
361
  if (this.mounted.get(chatId) !== entry) {
319
362
  return;
320
363
  }
321
- const since = entry.seq;
364
+ const since = this.sequence(chatId);
322
365
  const result = await this.transport.request('chat.attach', {
323
366
  chatId,
324
367
  historyLimit: HISTORY_PAGE,
@@ -336,13 +379,19 @@ export class ChatClient {
336
379
  }
337
380
  return;
338
381
  }
339
- entry.attached = true;
340
- if (result.seq !== undefined) {
341
- entry.seq = result.seq;
382
+ // A cache eviction while the request was in flight also invalidates its replay base.
383
+ if (result.events && since !== undefined && this.sequence(chatId) === undefined) {
384
+ await this.attach(chatId);
385
+ return;
342
386
  }
387
+ entry.attached = true;
343
388
  if (result.events && since !== undefined) {
344
- for (const event of result.events) {
345
- this.sink.apply(chatId, event);
389
+ if (this.sink.replay) {
390
+ this.sink.replay(chatId, result.events);
391
+ } else {
392
+ for (const event of result.events) {
393
+ this.sink.apply(chatId, event);
394
+ }
346
395
  }
347
396
  } else {
348
397
  const page = result.history === undefined ? undefined : {
@@ -352,10 +401,32 @@ export class ChatClient {
352
401
  };
353
402
  this.sink.reset(chatId, result.info, result.items, page);
354
403
  }
404
+ this.rememberSequence(chatId, result.seq);
355
405
  // A host without bookmarks or visuals sends none, and then this chat has none.
356
406
  this.sink.bookmarks(chatId, result.bookmarks ?? []);
357
407
  this.sink.visuals(chatId, result.visuals ?? []);
358
408
  }
409
+ sequence(chatId) {
410
+ if (!this.sink.historyIdentity) {
411
+ return this.mounted.get(chatId)?.seq;
412
+ }
413
+ const identity = this.sink.historyIdentity(chatId);
414
+ return identity === undefined ? undefined : this.checkpoints.get(identity);
415
+ }
416
+ rememberSequence(chatId, seq) {
417
+ const entry = this.mounted.get(chatId);
418
+ if (entry) {
419
+ entry.seq = seq;
420
+ }
421
+ const identity = this.sink.historyIdentity?.(chatId);
422
+ if (identity !== undefined) {
423
+ if (seq === undefined) {
424
+ this.checkpoints.delete(identity);
425
+ } else {
426
+ this.checkpoints.set(identity, seq);
427
+ }
428
+ }
429
+ }
359
430
  sendPreferences() {
360
431
  if (this.preferences === null) {
361
432
  return;
@@ -29,3 +29,4 @@ export { useSubagentSupport } from './subagent-support.js';
29
29
  export { crumbOf, openFromMain, composerWrites, canOpenSubagent, useSubagentView, useSubagentTrail, useSubagentItem, type SubagentStep, type SubagentTrail } from './subagent-view.js';
30
30
  export { stepTimelineMessage, jumpToTimelineItem, type TurnTarget, registerTurnJumper, jumpToTimelineTurn, type ItemJumpOptions } from './timeline-scroll.js';
31
31
  export { type StatusWord, statusLookOf, taskStatusWord, type StatusLook, type TaskState } from '../agents/status-look.js';
32
+ export { ChatPromptFeed } from './pending-prompts.js';
@@ -24,4 +24,5 @@ export { subagentTitle, summaryWordOf, stopOf } from './subagent-list.js';
24
24
  export { useSubagentSupport } from './subagent-support.js';
25
25
  export { crumbOf, openFromMain, composerWrites, canOpenSubagent, useSubagentView, useSubagentTrail, useSubagentItem } from './subagent-view.js';
26
26
  export { stepTimelineMessage, jumpToTimelineItem, registerTurnJumper, jumpToTimelineTurn } from './timeline-scroll.js';
27
- export { statusLookOf, taskStatusWord } from '../agents/status-look.js';
27
+ export { statusLookOf, taskStatusWord } from '../agents/status-look.js';
28
+ export { ChatPromptFeed } from './pending-prompts.js';
@@ -1,9 +1,17 @@
1
+ import { nameCollator } from '@adecore/ui/format';
1
2
  /*
2
3
  * A provider with more live models than this keeps the list short: the model that is picked and the default,
3
4
  * with the rest behind one row that opens all of them. A list of three hundred models is not one to scroll.
4
5
  */
5
6
  export const COMPACT_MODEL_LIMIT = 8;
6
7
  const NO_FAVORITES = () => new Set();
8
+ const VENDOR_ORDER = {
9
+ sensitivity: 'base'
10
+ };
11
+ const MODEL_ORDER = {
12
+ sensitivity: 'base',
13
+ numeric: true
14
+ };
7
15
  function matches(model, query) {
8
16
  return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query) || (model.vendor?.toLowerCase().includes(query) ?? false);
9
17
  }
@@ -15,16 +23,13 @@ export function modelsByVendor(models) {
15
23
  if (!models.some(model => model.vendor !== undefined)) {
16
24
  return models;
17
25
  }
26
+ const vendorOrder = nameCollator(VENDOR_ORDER).compare;
27
+ const modelOrder = nameCollator(MODEL_ORDER).compare;
18
28
  return [...models].sort((a, b) => {
19
29
  if (a.vendor === undefined || b.vendor === undefined) {
20
30
  return a.vendor === b.vendor ? 0 : a.vendor === undefined ? 1 : -1;
21
31
  }
22
- return a.vendor.localeCompare(b.vendor, undefined, {
23
- sensitivity: 'base'
24
- }) || a.name.localeCompare(b.name, undefined, {
25
- sensitivity: 'base',
26
- numeric: true
27
- });
32
+ return vendorOrder(a.vendor, b.vendor) || modelOrder(a.name, b.name);
28
33
  });
29
34
  }
30
35
  /* The starred models first, in the order they came, then the rest by maker. */
@@ -36,8 +36,9 @@ export declare function ticksWithHits(ticks: readonly ScrubberTick[], hitRows: r
36
36
  export declare function messageAt(layout: ScrubberLayout, y: number): number | null;
37
37
  export declare function slotOf(layout: ScrubberLayout, message: number): number | null;
38
38
  export declare function tickWidth(kind: TickKind, distanceFromPointer: number | null): number;
39
+ type MessageStarts = Pick<readonly number[], 'length' | 'at'>;
39
40
  export interface ReadingPosition {
40
- starts: readonly number[];
41
+ starts: MessageStarts;
41
42
  scrollTop: number;
42
43
  visibleHeight: number;
43
44
  }
@@ -47,5 +48,6 @@ export interface MessageRange {
47
48
  }
48
49
  export declare function messagesInView({ starts, scrollTop, visibleHeight }: ReadingPosition): MessageRange | null;
49
50
  export declare function slotInView(slot: TickSlot, range: MessageRange | null): boolean;
50
- export declare function stepMessage(starts: readonly number[], eligible: (index: number) => boolean, scrollTop: number, direction: -1 | 1): number | null;
51
+ export declare function stepMessage(starts: MessageStarts, eligible: (index: number) => boolean, scrollTop: number, direction: -1 | 1): number | null;
51
52
  export declare function threadPaddingLeft(width: number, strip: boolean, measure?: number): number;
53
+ export {};
@@ -197,7 +197,7 @@ function lastIndexAtOrBelow(length, valueAt, limit) {
197
197
  }
198
198
  /* The last index whose start lies at or above a line, or -1. */
199
199
  function lastAtOrAbove(starts, line) {
200
- return lastIndexAtOrBelow(starts.length, index => starts[index], line);
200
+ return lastIndexAtOrBelow(starts.length, index => starts.at(index), line);
201
201
  }
202
202
  /*
203
203
  * The messages on screen. A message counts from its own start to the start of the next one, so the
@@ -209,13 +209,13 @@ export function messagesInView({
209
209
  visibleHeight
210
210
  }) {
211
211
  const bottom = scrollTop + visibleHeight;
212
- if (starts.length === 0 || visibleHeight <= 0 || starts[0] >= bottom) {
212
+ if (starts.length === 0 || visibleHeight <= 0 || starts.at(0) >= bottom) {
213
213
  return null;
214
214
  }
215
215
  const first = Math.max(0, lastAtOrAbove(starts, scrollTop));
216
216
  let last = lastAtOrAbove(starts, bottom);
217
217
  // A row that starts on the very last line has not a pixel on screen yet.
218
- if (starts[last] >= bottom) {
218
+ if (starts.at(last) >= bottom) {
219
219
  last--;
220
220
  }
221
221
  return {
@@ -0,0 +1,15 @@
1
+ import type { PromptSubject } from '../prompts/logic/subjects.js';
2
+ import type { ChatScope } from '../scope.js';
3
+ export declare class ChatPromptFeed {
4
+ private readonly scope;
5
+ private readonly chatId;
6
+ private readonly changed;
7
+ private readonly children;
8
+ private unsubscribe;
9
+ private current;
10
+ private disposed;
11
+ constructor(scope: ChatScope, chatId: string, changed: (subjects: PromptSubject[]) => void);
12
+ start(): void;
13
+ dispose(): void;
14
+ private update;
15
+ }
@@ -0,0 +1,93 @@
1
+ import { CHAT_REQUEST_LIMITS } from '@adecore/agent-contracts';
2
+ import { useChats, waitingRequestsOf } from '../state/chats.js';
3
+ import { subagentTitle } from './subagent-list.js';
4
+ function stillWaiting(item, info) {
5
+ return info.status === 'needs-you' && (info.requests === undefined || info.requests.length >= CHAT_REQUEST_LIMITS.perChat || info.requests.some(request => request.requestId === item.requestId));
6
+ }
7
+ /* Follows descendant requests while the main chat is open, including children discovered in later events or older history pages. */
8
+ export class ChatPromptFeed {
9
+ scope;
10
+ chatId;
11
+ changed;
12
+ children = new Map();
13
+ unsubscribe;
14
+ current = null;
15
+ disposed = false;
16
+ constructor(scope, chatId, changed) {
17
+ this.scope = scope;
18
+ this.chatId = chatId;
19
+ this.changed = changed;
20
+ }
21
+ start() {
22
+ this.unsubscribe = useChats.subscribe(() => this.update());
23
+ this.update();
24
+ }
25
+ dispose() {
26
+ this.disposed = true;
27
+ this.unsubscribe?.();
28
+ for (const release of this.children.values()) {
29
+ release();
30
+ }
31
+ this.children.clear();
32
+ }
33
+ update() {
34
+ if (this.disposed) {
35
+ return;
36
+ }
37
+ const store = useChats.getState();
38
+ const subjects = new Map();
39
+ const seen = new Set();
40
+ const visit = (chatId, source) => {
41
+ if (seen.has(chatId)) {
42
+ return;
43
+ }
44
+ seen.add(chatId);
45
+ const key = this.scope.keyOf(chatId);
46
+ const row = store.byKey[key];
47
+ const info = store.statusByKey[key]?.info ?? row?.info;
48
+ for (const item of waitingRequestsOf(row)) {
49
+ if (info !== undefined && (chatId === this.chatId || stillWaiting(item, info))) {
50
+ subjects.set(`${chatId}:${item.requestId}`, {
51
+ kind: 'chat',
52
+ nodeId: chatId,
53
+ item,
54
+ ...(source === undefined ? {} : {
55
+ source
56
+ })
57
+ });
58
+ }
59
+ }
60
+ for (const item of Object.values(row?.structure ?? {})) {
61
+ if (item.kind !== 'subagent' || item.childId === undefined || seen.has(item.childId)) {
62
+ continue;
63
+ }
64
+ const childInfo = store.statusByKey[this.scope.keyOf(item.childId)]?.info;
65
+ if (item.status !== 'running' && childInfo?.status !== 'needs-you') {
66
+ continue;
67
+ }
68
+ if (!this.children.has(item.childId)) {
69
+ this.children.set(item.childId, this.scope.chats.observe(item.childId));
70
+ }
71
+ visit(item.childId, {
72
+ label: subagentTitle(item),
73
+ provider: childInfo?.provider
74
+ });
75
+ }
76
+ };
77
+ visit(this.chatId);
78
+ for (const [chatId, release] of this.children) {
79
+ if (!seen.has(chatId)) {
80
+ this.children.delete(chatId);
81
+ release();
82
+ }
83
+ }
84
+ const next = [...subjects.values()];
85
+ if (this.current === null || next.length !== this.current.length || next.some((subject, index) => {
86
+ const previous = this.current?.[index];
87
+ return subject.nodeId !== previous?.nodeId || subject.item !== previous.item || subject.source?.label !== previous.source?.label || subject.source?.provider !== previous.source?.provider;
88
+ })) {
89
+ this.current = next;
90
+ this.changed(next);
91
+ }
92
+ }
93
+ }
@@ -14,11 +14,15 @@ export function isChatReadingWidth(value) {
14
14
  export function readingMeasure(width) {
15
15
  return MEASURES[width];
16
16
  }
17
- /* What `.chat-column-content` and `.chat-composer-content` read their width from. */
17
+ /* A wide visual outgrows the reading column, but only a full-width chat lets it run past this. */
18
+ const WIDE_MEASURE = 1200;
19
+ /* What `.chat-column-content`, `.chat-wide-content` and `.chat-composer-content` read their width from. */
18
20
  export function readingWidthStyle(width) {
19
21
  const measure = MEASURES[width];
22
+ const bounded = Number.isFinite(measure);
20
23
  return {
21
- '--chat-measure': Number.isFinite(measure) ? `${measure}px` : '100%'
24
+ '--chat-measure': bounded ? `${measure}px` : '100%',
25
+ '--chat-wide-measure': bounded ? `${WIDE_MEASURE}px` : '100%'
22
26
  };
23
27
  }
24
28
  export function readingWidthLabel(width) {
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { HoldRelease } from '../prompt-hold.js';
3
- import { type PendingPrompt } from '../../prompts/logic/prompts.js';
3
+ import { type PromptSubject } from '../../prompts/logic/subjects.js';
4
4
  export interface PromptHold {
5
5
  held: boolean;
6
6
  releasedBy: HoldRelease | null;
@@ -8,7 +8,7 @@ export interface PromptHold {
8
8
  }
9
9
  export declare function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, hold, children }: {
10
10
  chatId: string;
11
- pending: PendingPrompt[];
11
+ pending: PromptSubject[];
12
12
  focused: boolean;
13
13
  onShowElsewhere?: () => void;
14
14
  disabled: boolean;