@adecore/agents-react 0.35.5 → 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 (58) 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/Pickers.js +6 -3
  12. package/dist/chat/ui/PromptComposer.d.ts +2 -2
  13. package/dist/chat/ui/PromptComposer.js +92 -98
  14. package/dist/chat/ui/RunSettings.js +2 -2
  15. package/dist/chat/ui/SubagentTimeline.js +128 -182
  16. package/dist/chat/ui/Timeline.js +86 -60
  17. package/dist/chat/ui/code-lines.d.ts +5 -0
  18. package/dist/chat/ui/code-lines.js +37 -0
  19. package/dist/chat/ui/composer/ComposerControls.js +1 -1
  20. package/dist/chat/ui/composer/use-pending-requests.d.ts +2 -2
  21. package/dist/chat/ui/composer/use-pending-requests.js +29 -37
  22. package/dist/chat/ui/highlighter.d.ts +1 -1
  23. package/dist/chat/ui/highlighter.js +4 -2
  24. package/dist/chat/ui/rows/MessageRows.js +1 -1
  25. package/dist/locales/en/agent-chat.d.ts +1 -0
  26. package/dist/locales/en/agent-chat.js +1 -0
  27. package/dist/locales/nl/agent-chat.d.ts +1 -0
  28. package/dist/locales/nl/agent-chat.js +1 -0
  29. package/dist/prompts/logic/subjects.d.ts +5 -0
  30. package/dist/prompts/ui/PromptCard.js +1 -1
  31. package/dist/prompts/ui/PromptView.js +104 -95
  32. package/dist/state/chats.d.ts +4 -0
  33. package/dist/state/chats.js +84 -48
  34. package/dist/theme.css +1 -1
  35. package/package.json +8 -8
  36. package/src/chat/chat-client.ts +92 -21
  37. package/src/chat/index.ts +2 -0
  38. package/src/chat/logic/picker-entries.ts +6 -4
  39. package/src/chat/logic/scrubber.ts +8 -6
  40. package/src/chat/pending-prompts.ts +108 -0
  41. package/src/chat/reading-width.ts +9 -2
  42. package/src/chat/ui/Pickers.tsx +3 -6
  43. package/src/chat/ui/PromptComposer.tsx +10 -7
  44. package/src/chat/ui/RunSettings.tsx +2 -2
  45. package/src/chat/ui/SubagentTimeline.tsx +1 -24
  46. package/src/chat/ui/Timeline.tsx +66 -40
  47. package/src/chat/ui/code-lines.ts +37 -0
  48. package/src/chat/ui/composer/ComposerControls.tsx +1 -1
  49. package/src/chat/ui/composer/use-pending-requests.ts +13 -21
  50. package/src/chat/ui/highlighter.ts +4 -3
  51. package/src/chat/ui/rows/MessageRows.tsx +1 -1
  52. package/src/locales/en/agent-chat.ts +1 -0
  53. package/src/locales/nl/agent-chat.ts +1 -0
  54. package/src/prompts/logic/subjects.ts +4 -1
  55. package/src/prompts/ui/PromptCard.tsx +1 -1
  56. package/src/prompts/ui/PromptView.tsx +7 -1
  57. package/src/state/chats.ts +63 -18
  58. package/src/theme.css +1 -1
@@ -42,6 +42,7 @@ export interface ChatSendExtras {
42
42
  }
43
43
 
44
44
  interface Mounted extends ChatOpenOptions, MountedEntry {
45
+ observerOnly?: boolean;
45
46
  // The last place in the chat's stream the store holds, so a reattach asks only for what came after.
46
47
  seq?: number;
47
48
  }
@@ -66,8 +67,10 @@ export class ChatClient {
66
67
  private readonly mounted = new MountedRegistry<Mounted>();
67
68
  private readonly unsubscribe: Array<() => void> = [];
68
69
  private readonly earlier = new Map<string, Promise<void>>();
70
+ private readonly observers = new Map<string, number>();
69
71
  private readonly inspections = new Map<string, Promise<ChatAttachResult>>();
70
72
  private preferences: ChatPreferencesPayload | null = null;
73
+ private checkpoints = new WeakMap<object, number>();
71
74
 
72
75
  constructor(transport: ChatTransport, sink: ChatSink, providers: ProviderSink | null = null) {
73
76
  this.transport = transport;
@@ -75,10 +78,10 @@ export class ChatClient {
75
78
  this.providers = providers;
76
79
  this.unsubscribe.push(
77
80
  transport.on('chat.event', ({ chatId, event, seq }) => {
81
+ const previous = this.sequence(chatId);
78
82
  this.sink.apply(chatId, event);
79
- const entry = this.mounted.get(chatId);
80
- if (entry && seq !== undefined) {
81
- entry.seq = seq;
83
+ if (previous !== undefined || (!this.sink.historyIdentity && this.mounted.get(chatId)?.attached)) {
84
+ this.rememberSequence(chatId, seq);
82
85
  }
83
86
  }),
84
87
  // Every chat on the machine, attached or not, so a node waiting on a person says so on a view nobody has open.
@@ -109,6 +112,31 @@ export class ChatClient {
109
112
  return read;
110
113
  }
111
114
 
115
+ /* Shares a live attachment without creating a chat; a visible composer and several ancestors may hold it together. */
116
+ observe(chatId: string): () => void {
117
+ this.observers.set(chatId, (this.observers.get(chatId) ?? 0) + 1);
118
+ if (!this.mounted.has(chatId)) {
119
+ this.mounted.set(chatId, { attached: false, observerOnly: true });
120
+ void this.attach(chatId).catch(() => undefined);
121
+ }
122
+ let released = false;
123
+ return () => {
124
+ if (released) {
125
+ return;
126
+ }
127
+ released = true;
128
+ const remaining = (this.observers.get(chatId) ?? 1) - 1;
129
+ if (remaining > 0) {
130
+ this.observers.set(chatId, remaining);
131
+ return;
132
+ }
133
+ this.observers.delete(chatId);
134
+ if (this.mounted.get(chatId)?.observerOnly) {
135
+ void this.detach(chatId);
136
+ }
137
+ };
138
+ }
139
+
112
140
  /* Answers false when the transport is not connected; the chat opens once it is. */
113
141
  async open(chatId: string, options: ChatOpenOptions): Promise<boolean> {
114
142
  this.mounted.set(chatId, { ...options, attached: false });
@@ -135,7 +163,8 @@ export class ChatClient {
135
163
  }
136
164
  // The account belonged to the CLI the chat leaves, and the seq to its stream: the new chat's
137
165
  // stream starts over, so offering it the old seq would read as nothing missed and reset nothing.
138
- const { attached: _attached, account: _account, seq: _seq, ...options } = entry;
166
+ const { attached: _attached, account: _account, seq: _seq, observerOnly: _observerOnly, ...options } = entry;
167
+ this.rememberSequence(chatId, undefined);
139
168
  this.mounted.delete(chatId);
140
169
  await this.transport.request('chat.kill', { chatId });
141
170
  try {
@@ -157,6 +186,10 @@ export class ChatClient {
157
186
 
158
187
  async detach(chatId: string): Promise<void> {
159
188
  const entry = this.mounted.get(chatId);
189
+ if (entry && this.observers.has(chatId)) {
190
+ entry.observerOnly = true;
191
+ return;
192
+ }
160
193
  this.mounted.delete(chatId);
161
194
  if (!entry?.attached) {
162
195
  return;
@@ -268,13 +301,17 @@ export class ChatClient {
268
301
  }
269
302
  const read = this.transport
270
303
  .request('chat.history', { chatId, cursor, limit: HISTORY_PAGE })
271
- .then((page) => this.sink.prepend(chatId, cursor, page))
304
+ .then((page) => {
305
+ const seq = this.sequence(chatId);
306
+ this.sink.prepend(chatId, cursor, page);
307
+ this.rememberSequence(chatId, seq);
308
+ })
272
309
  .catch(async (e: unknown) => {
273
310
  const entry = this.mounted.get(chatId);
274
311
  if (errorCode(e) !== 'history-expired' || !entry) {
275
312
  throw e;
276
313
  }
277
- delete entry.seq;
314
+ this.rememberSequence(chatId, undefined);
278
315
  await this.attach(chatId);
279
316
  })
280
317
  .finally(() => this.earlier.delete(chatId));
@@ -292,6 +329,8 @@ export class ChatClient {
292
329
  off();
293
330
  }
294
331
  this.unsubscribe.length = 0;
332
+ this.observers.clear();
333
+ this.checkpoints = new WeakMap();
295
334
  for (const chatId of [...this.mounted.keys()]) {
296
335
  void this.detach(chatId);
297
336
  }
@@ -307,20 +346,22 @@ export class ChatClient {
307
346
  if (!entry) {
308
347
  return;
309
348
  }
310
- await this.transport.request('chat.create', {
311
- chatId,
312
- provider: entry.provider,
313
- account: entry.account,
314
- cwd: entry.cwd,
315
- resume: entry.resume,
316
- selection: entry.selection,
317
- runtimeMode: entry.runtimeMode
318
- });
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
+ }
319
360
  // Closed or opened again in the meantime: an opening that is still there attaches itself.
320
361
  if (this.mounted.get(chatId) !== entry) {
321
362
  return;
322
363
  }
323
- const since = entry.seq;
364
+ const since = this.sequence(chatId);
324
365
  const result = await this.transport.request('chat.attach', { chatId, historyLimit: HISTORY_PAGE, ...(since === undefined ? {} : { since }) });
325
366
  const current = this.mounted.get(chatId);
326
367
  if (current !== entry) {
@@ -330,24 +371,54 @@ export class ChatClient {
330
371
  }
331
372
  return;
332
373
  }
333
- entry.attached = true;
334
- if (result.seq !== undefined) {
335
- entry.seq = result.seq;
374
+ // A cache eviction while the request was in flight also invalidates its replay base.
375
+ if (result.events && since !== undefined && this.sequence(chatId) === undefined) {
376
+ await this.attach(chatId);
377
+ return;
336
378
  }
379
+ entry.attached = true;
337
380
  if (result.events && since !== undefined) {
338
- for (const event of result.events) {
339
- this.sink.apply(chatId, event);
381
+ if (this.sink.replay) {
382
+ this.sink.replay(chatId, result.events);
383
+ } else {
384
+ for (const event of result.events) {
385
+ this.sink.apply(chatId, event);
386
+ }
340
387
  }
341
388
  } else {
342
389
  const page =
343
390
  result.history === undefined ? undefined : { history: result.history, pending: result.pending ?? [], subagents: result.subagents ?? [] };
344
391
  this.sink.reset(chatId, result.info, result.items, page);
345
392
  }
393
+ this.rememberSequence(chatId, result.seq);
346
394
  // A host without bookmarks or visuals sends none, and then this chat has none.
347
395
  this.sink.bookmarks(chatId, result.bookmarks ?? []);
348
396
  this.sink.visuals(chatId, result.visuals ?? []);
349
397
  }
350
398
 
399
+ private sequence(chatId: string): number | undefined {
400
+ if (!this.sink.historyIdentity) {
401
+ return this.mounted.get(chatId)?.seq;
402
+ }
403
+ const identity = this.sink.historyIdentity(chatId);
404
+ return identity === undefined ? undefined : this.checkpoints.get(identity);
405
+ }
406
+
407
+ private rememberSequence(chatId: string, seq: number | undefined): void {
408
+ const entry = this.mounted.get(chatId);
409
+ if (entry) {
410
+ entry.seq = seq;
411
+ }
412
+ const identity = this.sink.historyIdentity?.(chatId);
413
+ if (identity !== undefined) {
414
+ if (seq === undefined) {
415
+ this.checkpoints.delete(identity);
416
+ } else {
417
+ this.checkpoints.set(identity, seq);
418
+ }
419
+ }
420
+ }
421
+
351
422
  private sendPreferences(): void {
352
423
  if (this.preferences === null) {
353
424
  return;
package/src/chat/index.ts CHANGED
@@ -78,3 +78,5 @@ export {
78
78
  } from './subagent-view';
79
79
  export { stepTimelineMessage, jumpToTimelineItem, type TurnTarget, registerTurnJumper, jumpToTimelineTurn, type ItemJumpOptions } from './timeline-scroll';
80
80
  export { type StatusWord, statusLookOf, taskStatusWord, type StatusLook, type TaskState } from '../agents/status-look';
81
+
82
+ export { ChatPromptFeed } from './pending-prompts';
@@ -1,4 +1,5 @@
1
1
  import type { AgentKind, ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
+ import { nameCollator } from '@adecore/ui/format';
2
3
 
3
4
  /*
4
5
  * A provider with more live models than this keeps the list short: the model that is picked and the default,
@@ -21,6 +22,8 @@ export interface PickerCurrent {
21
22
  export type FavoritesOf = (kind: AgentKind) => ReadonlySet<string>;
22
23
 
23
24
  const NO_FAVORITES: FavoritesOf = () => new Set();
25
+ const VENDOR_ORDER: Intl.CollatorOptions = { sensitivity: 'base' };
26
+ const MODEL_ORDER: Intl.CollatorOptions = { sensitivity: 'base', numeric: true };
24
27
 
25
28
  function matches(model: ModelInfo, query: string): boolean {
26
29
  return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query) || (model.vendor?.toLowerCase().includes(query) ?? false);
@@ -34,14 +37,13 @@ export function modelsByVendor(models: ModelInfo[]): ModelInfo[] {
34
37
  if (!models.some((model) => model.vendor !== undefined)) {
35
38
  return models;
36
39
  }
40
+ const vendorOrder = nameCollator(VENDOR_ORDER).compare;
41
+ const modelOrder = nameCollator(MODEL_ORDER).compare;
37
42
  return [...models].sort((a, b) => {
38
43
  if (a.vendor === undefined || b.vendor === undefined) {
39
44
  return a.vendor === b.vendor ? 0 : a.vendor === undefined ? 1 : -1;
40
45
  }
41
- return (
42
- a.vendor.localeCompare(b.vendor, undefined, { sensitivity: 'base' }) ||
43
- a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
44
- );
46
+ return vendorOrder(a.vendor, b.vendor) || modelOrder(a.name, b.name);
45
47
  });
46
48
  }
47
49
 
@@ -207,14 +207,16 @@ function lastIndexAtOrBelow(length: number, valueAt: (index: number) => number,
207
207
  return found;
208
208
  }
209
209
 
210
+ type MessageStarts = Pick<readonly number[], 'length' | 'at'>;
211
+
210
212
  /* The last index whose start lies at or above a line, or -1. */
211
- function lastAtOrAbove(starts: readonly number[], line: number): number {
212
- return lastIndexAtOrBelow(starts.length, (index) => starts[index]!, line);
213
+ function lastAtOrAbove(starts: MessageStarts, line: number): number {
214
+ return lastIndexAtOrBelow(starts.length, (index) => starts.at(index)!, line);
213
215
  }
214
216
 
215
217
  export interface ReadingPosition {
216
218
  /* Where each message's row starts in the thread, in the virtualizer's measurements. */
217
- starts: readonly number[];
219
+ starts: MessageStarts;
218
220
  scrollTop: number;
219
221
  /* The height of the thread a person can read, without what the composer covers. */
220
222
  visibleHeight: number;
@@ -231,13 +233,13 @@ export interface MessageRange {
231
233
  */
232
234
  export function messagesInView({ starts, scrollTop, visibleHeight }: ReadingPosition): MessageRange | null {
233
235
  const bottom = scrollTop + visibleHeight;
234
- if (starts.length === 0 || visibleHeight <= 0 || starts[0]! >= bottom) {
236
+ if (starts.length === 0 || visibleHeight <= 0 || starts.at(0)! >= bottom) {
235
237
  return null;
236
238
  }
237
239
  const first = Math.max(0, lastAtOrAbove(starts, scrollTop));
238
240
  let last = lastAtOrAbove(starts, bottom);
239
241
  // A row that starts on the very last line has not a pixel on screen yet.
240
- if (starts[last]! >= bottom) {
242
+ if (starts.at(last)! >= bottom) {
241
243
  last--;
242
244
  }
243
245
  return { first, last: Math.max(first, last) };
@@ -253,7 +255,7 @@ export function slotInView(slot: TickSlot, range: MessageRange | null): boolean
253
255
  * message's start at the top, so back from there is the one before it, and back from the middle of
254
256
  * a long answer is the question it belongs to. A pixel of slack absorbs rounding in the scroller.
255
257
  */
256
- export function stepMessage(starts: readonly number[], eligible: (index: number) => boolean, scrollTop: number, direction: -1 | 1): number | null {
258
+ export function stepMessage(starts: MessageStarts, eligible: (index: number) => boolean, scrollTop: number, direction: -1 | 1): number | null {
257
259
  if (direction === -1) {
258
260
  for (let i = lastAtOrAbove(starts, scrollTop - 1.5); i >= 0; i--) {
259
261
  if (eligible(i)) {
@@ -0,0 +1,108 @@
1
+ import { CHAT_REQUEST_LIMITS, type ChatInfo } from '@adecore/agent-contracts';
2
+ import type { PromptSubject } from '../prompts/logic/subjects';
3
+ import type { PendingPrompt } from '../prompts/logic/prompts';
4
+ import type { ChatScope } from '../scope';
5
+ import { useChats, waitingRequestsOf } from '../state/chats';
6
+ import { subagentTitle } from './subagent-list';
7
+
8
+ type ChatPromptSubject = Extract<PromptSubject, { kind: 'chat' }>;
9
+
10
+ function stillWaiting(item: PendingPrompt, info: ChatInfo): boolean {
11
+ return (
12
+ info.status === 'needs-you' &&
13
+ (info.requests === undefined ||
14
+ info.requests.length >= CHAT_REQUEST_LIMITS.perChat ||
15
+ info.requests.some((request) => request.requestId === item.requestId))
16
+ );
17
+ }
18
+
19
+ /* Follows descendant requests while the main chat is open, including children discovered in later events or older history pages. */
20
+ export class ChatPromptFeed {
21
+ private readonly scope: ChatScope;
22
+ private readonly chatId: string;
23
+ private readonly changed: (subjects: PromptSubject[]) => void;
24
+ private readonly children = new Map<string, () => void>();
25
+ private unsubscribe: (() => void) | undefined;
26
+ private current: ChatPromptSubject[] | null = null;
27
+ private disposed = false;
28
+
29
+ constructor(scope: ChatScope, chatId: string, changed: (subjects: PromptSubject[]) => void) {
30
+ this.scope = scope;
31
+ this.chatId = chatId;
32
+ this.changed = changed;
33
+ }
34
+
35
+ start(): void {
36
+ this.unsubscribe = useChats.subscribe(() => this.update());
37
+ this.update();
38
+ }
39
+
40
+ dispose(): void {
41
+ this.disposed = true;
42
+ this.unsubscribe?.();
43
+ for (const release of this.children.values()) {
44
+ release();
45
+ }
46
+ this.children.clear();
47
+ }
48
+
49
+ private update(): void {
50
+ if (this.disposed) {
51
+ return;
52
+ }
53
+ const store = useChats.getState();
54
+ const subjects = new Map<string, ChatPromptSubject>();
55
+ const seen = new Set<string>();
56
+ const visit = (chatId: string, source?: ChatPromptSubject['source']): void => {
57
+ if (seen.has(chatId)) {
58
+ return;
59
+ }
60
+ seen.add(chatId);
61
+ const key = this.scope.keyOf(chatId);
62
+ const row = store.byKey[key];
63
+ const info = store.statusByKey[key]?.info ?? row?.info;
64
+ for (const item of waitingRequestsOf(row)) {
65
+ if (info !== undefined && (chatId === this.chatId || stillWaiting(item, info))) {
66
+ subjects.set(`${chatId}:${item.requestId}`, { kind: 'chat', nodeId: chatId, item, ...(source === undefined ? {} : { source }) });
67
+ }
68
+ }
69
+ for (const item of Object.values(row?.structure ?? {})) {
70
+ if (item.kind !== 'subagent' || item.childId === undefined || seen.has(item.childId)) {
71
+ continue;
72
+ }
73
+ const childInfo = store.statusByKey[this.scope.keyOf(item.childId)]?.info;
74
+ if (item.status !== 'running' && childInfo?.status !== 'needs-you') {
75
+ continue;
76
+ }
77
+ if (!this.children.has(item.childId)) {
78
+ this.children.set(item.childId, this.scope.chats.observe(item.childId));
79
+ }
80
+ visit(item.childId, { label: subagentTitle(item), provider: childInfo?.provider });
81
+ }
82
+ };
83
+ visit(this.chatId);
84
+ for (const [chatId, release] of this.children) {
85
+ if (!seen.has(chatId)) {
86
+ this.children.delete(chatId);
87
+ release();
88
+ }
89
+ }
90
+ const next = [...subjects.values()];
91
+ if (
92
+ this.current === null ||
93
+ next.length !== this.current.length ||
94
+ next.some((subject, index) => {
95
+ const previous = this.current?.[index];
96
+ return (
97
+ subject.nodeId !== previous?.nodeId ||
98
+ subject.item !== previous.item ||
99
+ subject.source?.label !== previous.source?.label ||
100
+ subject.source?.provider !== previous.source?.provider
101
+ );
102
+ })
103
+ ) {
104
+ this.current = next;
105
+ this.changed(next);
106
+ }
107
+ }
108
+ }
@@ -24,10 +24,17 @@ export function readingMeasure(width: ChatReadingWidth): number {
24
24
  return MEASURES[width];
25
25
  }
26
26
 
27
- /* What `.chat-column-content` and `.chat-composer-content` read their width from. */
27
+ /* A wide visual outgrows the reading column, but only a full-width chat lets it run past this. */
28
+ const WIDE_MEASURE = 1200;
29
+
30
+ /* What `.chat-column-content`, `.chat-wide-content` and `.chat-composer-content` read their width from. */
28
31
  export function readingWidthStyle(width: ChatReadingWidth): CSSProperties {
29
32
  const measure = MEASURES[width];
30
- return { '--chat-measure': Number.isFinite(measure) ? `${measure}px` : '100%' } as CSSProperties;
33
+ const bounded = Number.isFinite(measure);
34
+ return {
35
+ '--chat-measure': bounded ? `${measure}px` : '100%',
36
+ '--chat-wide-measure': bounded ? `${WIDE_MEASURE}px` : '100%'
37
+ } as CSSProperties;
31
38
  }
32
39
 
33
40
  export function readingWidthLabel(width: ChatReadingWidth): string {
@@ -13,15 +13,12 @@ import { ModelMarks } from './ModelMarks';
13
13
  import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
14
14
  import { favoriteModelsOf, toggleFavoriteModel, useChatPreferences } from '../preferences';
15
15
  import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
16
- import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
16
+ import { Button, Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
17
17
  import { useHostMachineLabel } from '../../host-hooks';
18
18
 
19
19
  const triggerClass =
20
20
  'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
21
21
 
22
- const chipClass =
23
- 'flex h-6 min-w-0 shrink items-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2 text-xs text-text hover:bg-surface-active data-[popup-open]:bg-surface-active';
24
-
25
22
  interface ModelPickerProps {
26
23
  /* Whose models may be picked: every installed chat CLI before the first message, the chat's own after it. */
27
24
  providers: ProviderInfo[];
@@ -35,7 +32,7 @@ interface ModelPickerProps {
35
32
  kbd?: string | null;
36
33
  /* Where the popup opens: over the composer by default, below the trigger in a form. */
37
34
  side?: 'top' | 'bottom';
38
- /* `chip` names the provider beside the model, on a ground of its own, for a picker that sits in a card or a form. */
35
+ /* `chip` names the provider beside the model on a secondary button, for a picker that sits in a card or a form. */
39
36
  trigger?: 'plain' | 'chip';
40
37
  /* Where the focus goes when the popup closes; by default back to the trigger. */
41
38
  finalFocus?: RefObject<HTMLElement | null>;
@@ -149,7 +146,7 @@ export function ModelPicker({
149
146
  <Popover.Root open={open} onOpenChange={setOpen}>
150
147
  <Tooltip label={owner ? `${owner.name} · ${current}` : t('pickers.model.choose')} kbd={kbd ?? undefined}>
151
148
  {trigger === 'chip' ? (
152
- <Popover.Trigger className={chipClass}>
149
+ <Popover.Trigger render={<Button variant="secondary" size="sm" className="min-w-0 shrink gap-1.5" />}>
153
150
  {chip.mark === 'logo' ? <ProviderLogo provider={provider as UsageProvider} size={12} /> : <AgentIcon kind={provider} size={12} />}
154
151
  <span className="truncate">{chip.name}</span>
155
152
  <span className="max-w-32 truncate text-text-muted">{chip.model}</span>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
3
3
  import { ArrowDown, Hand, MessageCircleQuestionMark } from 'lucide-react';
4
4
  import type { HoldRelease } from '../prompt-hold';
5
5
  import { focusPromptHeading, focusPromptStart } from '../../prompts/logic/focus';
6
- import { orderPrompts, type PendingPrompt } from '../../prompts/logic/prompts';
6
+ import { orderPrompts } from '../../prompts/logic/prompts';
7
7
  import { answerPrompt, isBlockingSubject, promptCreatedAt, promptIdOf, type PromptSubject } from '../../prompts/logic/subjects';
8
8
  import { useFocusAfterAnswer } from '../../prompts/logic/useFocusAfterAnswer';
9
9
  import { usePromptSession } from '../../prompts/logic/usePromptSession';
@@ -12,6 +12,7 @@ import { useChatHost } from '../../host';
12
12
  import { useChatScope } from '../../scope';
13
13
  import { useChatActions } from '../actions';
14
14
  import { Button, Icon } from '@adecore/ui';
15
+ import { useProviders } from '../../state/providers';
15
16
  import { useHostExtraPrompts } from '../../host-hooks';
16
17
 
17
18
  /* Keeps a prompt card behind the draft while the person types; `released` says what lifted the hold last. */
@@ -39,7 +40,7 @@ export function PromptComposer({
39
40
  children
40
41
  }: {
41
42
  chatId: string;
42
- pending: PendingPrompt[];
43
+ pending: PromptSubject[];
43
44
  focused: boolean;
44
45
  /* Set where the prompts are answered somewhere else; the composer only says where they went, and this goes there. */
45
46
  onShowElsewhere?: () => void;
@@ -56,13 +57,11 @@ export function PromptComposer({
56
57
  const { id } = useChatScope();
57
58
  const host = useChatHost();
58
59
  const actions = useChatActions();
60
+ const providers = useProviders((row) => row.providers);
59
61
  const elsewhere = onShowElsewhere !== undefined;
60
62
  // A card the app raises itself waits in the chat beside the CLI's own requests.
61
63
  const extra = useHostExtraPrompts(id, chatId);
62
- const subjects: PromptSubject[] = [
63
- ...pending.map((item): PromptSubject => ({ kind: 'chat', nodeId: chatId, item })),
64
- ...extra.map((prompt): PromptSubject => ({ kind: 'host', nodeId: chatId, prompt }))
65
- ];
64
+ const subjects: PromptSubject[] = [...pending, ...extra.map((prompt): PromptSubject => ({ kind: 'host', nodeId: chatId, prompt }))];
66
65
  const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
67
66
  const ref = useRef<HTMLDivElement>(null);
68
67
  const { active, activeId: activeKey } = session;
@@ -125,7 +124,11 @@ export function PromptComposer({
125
124
  }}
126
125
  more={session.waiting.length - 1}
127
126
  hasDraft={hasDraft}
128
- denyReason={denyReason}
127
+ denyReason={
128
+ active.kind === 'chat' && active.source?.provider !== undefined
129
+ ? providers.find((provider) => provider.kind === active.source?.provider)?.capabilities.denyReason === true
130
+ : denyReason
131
+ }
129
132
  disabled={disabled}
130
133
  sending={session.sending}
131
134
  error={session.errorOf(promptIdOf(active))}
@@ -312,7 +312,7 @@ export function RunSettings({
312
312
  disabled={disabled}
313
313
  className={clsx(
314
314
  'flex h-8 min-w-0 items-center gap-2 rounded-full border pr-3 pl-2.5 text-xs whitespace-nowrap text-text-muted @max-xl/composer:gap-1.5 @max-sm/composer:pr-2.5',
315
- open ? 'border-accent bg-accent-soft' : 'border-border hover:bg-surface-hover'
315
+ open ? 'border-accent bg-accent-soft' : 'border-border hover:bg-surface-hover-edged'
316
316
  )}
317
317
  >
318
318
  <AgentIcon kind={provider} size={14} className="shrink-0" />
@@ -665,7 +665,7 @@ function OptionRow({ option, selection, onChange }: { option: ModelOptionDescrip
665
665
  value={value}
666
666
  onValueChange={(next: string) => onChange(next)}
667
667
  aria-label={option.label}
668
- className="flex gap-0.5 rounded-md bg-surface-sunken p-0.5"
668
+ className="flex gap-0.5 rounded-md bg-surface-sunken p-0.5 dark:bg-surface-idle"
669
669
  >
670
670
  {option.choices.map((choice) => (
671
671
  <Menu.RadioItem
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Bot, LoaderCircle } from 'lucide-react';
4
4
  import { deriveTimelineRows } from '../logic/timeline';
@@ -9,8 +9,6 @@ import { EMPTY_TARGET, readTimelineTarget, type TimelineTarget } from '../logic/
9
9
  import { openBelow, useSubagentTrail, type SubagentStep } from '../subagent-view';
10
10
  import { Row } from './rows/Rows';
11
11
  import { ReplyContext } from './reply-context';
12
- import { PromptComposer } from './PromptComposer';
13
- import { useProviders } from '../../state/providers';
14
12
  import { SubagentInfo } from './SubagentInfo';
15
13
  import { TimelineMenuPopup } from './TimelineMenu';
16
14
  import { FOLLOW_THRESHOLD_PX, rowRhythm } from './rows/row-rhythm';
@@ -33,14 +31,8 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
33
31
  const { t } = useTranslation('agent-chat');
34
32
  const scope = useChatScope();
35
33
  const { commandLooks } = useChatHost();
36
- const connected = useSyncExternalStore(
37
- (changed) => scope.transport.subscribeStatus(changed),
38
- () => scope.transport.status === 'open',
39
- () => false
40
- );
41
34
  const { trail, show } = useSubagentTrail(chatId);
42
35
  const [state, setState] = useState<SubagentConversationState>(INITIAL_CONVERSATION);
43
- const denyReason = useProviders((row) => row.providers.find((provider) => provider.kind === state.context?.provider)?.capabilities.denyReason === true);
44
36
  const controller = useRef<SubagentConversation | null>(null);
45
37
  const scrollRef = useRef<HTMLDivElement>(null);
46
38
  const threadRef = useRef<HTMLDivElement>(null);
@@ -183,21 +175,6 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
183
175
  </ReplyContext.Provider>
184
176
  </FileLinkContext.Provider>
185
177
  )}
186
- {state.context?.chatId !== undefined && state.pending.length > 0 && (
187
- <div className="chat-column-content shrink-0">
188
- <PromptComposer
189
- key={state.context.chatId}
190
- chatId={state.context.chatId}
191
- pending={state.pending}
192
- focused={false}
193
- disabled={!connected}
194
- hasDraft={false}
195
- denyReason={denyReason}
196
- >
197
- {null}
198
- </PromptComposer>
199
- </div>
200
- )}
201
178
  </div>
202
179
  );
203
180
  }