@adecore/agents-react 0.21.1 → 0.21.2

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 (97) hide show
  1. package/dist/agents/status-look.js +2 -3
  2. package/dist/chat/chat-client.d.ts +1 -1
  3. package/dist/chat/chat-client.js +2 -2
  4. package/dist/chat/drafts.js +6 -12
  5. package/dist/chat/logic/ansi.js +5 -1
  6. package/dist/chat/logic/fork.js +8 -9
  7. package/dist/chat/logic/limit.js +17 -14
  8. package/dist/chat/logic/resume-compaction.js +2 -11
  9. package/dist/chat/logic/scrubber.js +9 -18
  10. package/dist/chat/logic/timeline.js +15 -10
  11. package/dist/chat/logic/tool-catalog.js +3 -4
  12. package/dist/chat/mentions.js +5 -6
  13. package/dist/chat/runtime-modes.js +2 -4
  14. package/dist/chat/stash.js +1 -3
  15. package/dist/chat/thumbnails.js +3 -4
  16. package/dist/chat/timeline-scroll.js +39 -45
  17. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  18. package/dist/chat/ui/CodeBlock.js +2 -3
  19. package/dist/chat/ui/Composer.js +57 -69
  20. package/dist/chat/ui/Markdown.js +11 -11
  21. package/dist/chat/ui/Pickers.js +5 -9
  22. package/dist/chat/ui/QuoteButton.js +2 -3
  23. package/dist/chat/ui/RunSettings.js +8 -2
  24. package/dist/chat/ui/Scrubber.js +2 -3
  25. package/dist/chat/ui/SubagentControls.js +1 -3
  26. package/dist/chat/ui/Timeline.js +15 -18
  27. package/dist/chat/ui/TimelineMenu.js +3 -9
  28. package/dist/chat/ui/UnifiedDiff.js +3 -5
  29. package/dist/chat/ui/VisualFrame.js +3 -4
  30. package/dist/chat/ui/composer/keys.js +19 -11
  31. package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
  32. package/dist/chat/ui/rows/MessageRows.js +2 -3
  33. package/dist/chat/ui/rows/WorkRows.d.ts +4 -3
  34. package/dist/chat/ui/rows/WorkRows.js +19 -14
  35. package/dist/chat/visual-viewport.d.ts +1 -1
  36. package/dist/chat/visual-viewport.js +9 -8
  37. package/dist/port-transport.js +4 -8
  38. package/dist/state/chats.js +10 -14
  39. package/dist/state/usage.js +44 -49
  40. package/dist/storage.js +7 -7
  41. package/dist/theme.css +6 -14
  42. package/dist/usage/LimitsList.js +1 -2
  43. package/dist/usage/UsageBreakdown.js +1 -3
  44. package/dist/usage/UsageDialog.js +2 -4
  45. package/dist/usage/format.js +6 -6
  46. package/dist/usage/money.js +1 -5
  47. package/dist/usage/summary.js +2 -4
  48. package/package.json +4 -4
  49. package/src/agents/status-look.ts +2 -3
  50. package/src/chat/chat-client.ts +2 -3
  51. package/src/chat/drafts.ts +6 -14
  52. package/src/chat/logic/ansi.ts +5 -1
  53. package/src/chat/logic/fork.ts +8 -9
  54. package/src/chat/logic/limit.ts +17 -16
  55. package/src/chat/logic/resume-compaction.ts +2 -11
  56. package/src/chat/logic/scrubber.ts +13 -17
  57. package/src/chat/logic/timeline.ts +22 -11
  58. package/src/chat/logic/tool-catalog.ts +7 -6
  59. package/src/chat/mentions.ts +5 -6
  60. package/src/chat/runtime-modes.ts +2 -4
  61. package/src/chat/stash.ts +1 -3
  62. package/src/chat/thumbnails.ts +3 -4
  63. package/src/chat/timeline-scroll.ts +43 -52
  64. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  65. package/src/chat/ui/CodeBlock.tsx +2 -3
  66. package/src/chat/ui/Composer.tsx +66 -86
  67. package/src/chat/ui/Markdown.tsx +13 -17
  68. package/src/chat/ui/Pickers.tsx +5 -9
  69. package/src/chat/ui/QuoteButton.tsx +2 -3
  70. package/src/chat/ui/RunSettings.tsx +9 -2
  71. package/src/chat/ui/Scrubber.tsx +2 -3
  72. package/src/chat/ui/SubagentControls.tsx +1 -3
  73. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  74. package/src/chat/ui/Timeline.tsx +18 -20
  75. package/src/chat/ui/TimelineMenu.tsx +3 -9
  76. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  77. package/src/chat/ui/VisualFrame.tsx +3 -4
  78. package/src/chat/ui/composer/keys.ts +21 -11
  79. package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
  80. package/src/chat/ui/rehype-chips.ts +1 -1
  81. package/src/chat/ui/rehype-fade.ts +1 -1
  82. package/src/chat/ui/remark-html-as-text.ts +1 -1
  83. package/src/chat/ui/rows/MessageRows.tsx +3 -4
  84. package/src/chat/ui/rows/WorkRows.tsx +86 -69
  85. package/src/chat/visual-viewport.ts +10 -8
  86. package/src/host.ts +1 -3
  87. package/src/port-transport.ts +4 -8
  88. package/src/state/chats.ts +11 -14
  89. package/src/state/usage.ts +49 -60
  90. package/src/storage.ts +7 -7
  91. package/src/theme.css +6 -14
  92. package/src/usage/LimitsList.tsx +2 -4
  93. package/src/usage/UsageBreakdown.tsx +2 -5
  94. package/src/usage/UsageDialog.tsx +2 -4
  95. package/src/usage/format.ts +6 -6
  96. package/src/usage/money.ts +1 -5
  97. package/src/usage/summary.ts +2 -4
@@ -1,8 +1,7 @@
1
1
  import { CircleCheck, CircleSlash, CircleX, Hourglass, LoaderCircle } from 'lucide-react';
2
2
  /*
3
- * One state, one mark: an entry in the sub-agent flyout, a task on its node (`TaskMark`), what finished
4
- * in the toolbar (`StatusSummary`) and work in progress in a toast. A spinner stops under reduced
5
- * motion with every other animation (`styles.css`).
3
+ * One state, one mark, wherever work is drawn: a sub-agent entry, a task, a summary or a toast. A
4
+ * spinner stops under reduced motion with every other animation.
6
5
  */
7
6
  const LOOKS = {
8
7
  running: { icon: LoaderCircle, tone: 'text-status-running', spins: true },
@@ -26,9 +26,9 @@ export declare class ChatClient {
26
26
  private readonly mounted;
27
27
  private readonly unsubscribe;
28
28
  private readonly earlier;
29
+ private readonly inspections;
29
30
  private preferences;
30
31
  constructor(transport: ChatTransport, sink: ChatSink, providers?: ProviderSink | null);
31
- private readonly inspections;
32
32
  inspect(chatId: string): Promise<ChatAttachResult>;
33
33
  open(chatId: string, options: ChatOpenOptions): Promise<boolean>;
34
34
  retarget(chatId: string, provider: AgentKind, selection: ModelSelection): Promise<void>;
@@ -15,6 +15,7 @@ export class ChatClient {
15
15
  mounted = new MountedRegistry();
16
16
  unsubscribe = [];
17
17
  earlier = new Map();
18
+ inspections = new Map();
18
19
  preferences = null;
19
20
  constructor(transport, sink, providers = null) {
20
21
  this.transport = transport;
@@ -34,7 +35,6 @@ export class ChatClient {
34
35
  void this.loadStatuses();
35
36
  }
36
37
  }
37
- inspections = new Map();
38
38
  inspect(chatId) {
39
39
  const pending = this.inspections.get(chatId);
40
40
  if (pending) {
@@ -277,7 +277,7 @@ export class ChatClient {
277
277
  this.sendPreferences();
278
278
  void this.loadProviders();
279
279
  void this.loadStatuses();
280
- void this.mounted.reattachAll((chatId) => this.attach(chatId).then(() => undefined));
280
+ void this.mounted.reattachAll((chatId) => this.attach(chatId));
281
281
  return;
282
282
  }
283
283
  this.mounted.detachAll();
@@ -4,12 +4,6 @@ import { persistedJson } from './persisted-json.js';
4
4
  const storageKey = () => chatStorageKey('drafts');
5
5
  export const EMPTY_DRAFT = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '' };
6
6
  const storage = persistedJson(storageKey, (raw) => (raw ? JSON.parse(raw) : {}), {}, () => chatStorageLegacyKeys('drafts'));
7
- function readAll() {
8
- return storage.read();
9
- }
10
- function store(drafts) {
11
- return storage.write(drafts);
12
- }
13
7
  export function isEmptyDraft(draft) {
14
8
  return draft.text.trim() === '' && draft.attachments.length === 0 && draft.quote === '';
15
9
  }
@@ -17,7 +11,7 @@ export function isEmptyDraft(draft) {
17
11
  * Which chats hold an unsent prompt, for a dot beside them in a list of chats. The drafts themselves
18
12
  * stay in storage: a node that is not on screen has no composer, and this is all anyone else needs.
19
13
  */
20
- export const useDrafts = createChatStore(() => ({ ids: [] }), (store) => store.setState({ ids: Object.keys(readAll()) }));
14
+ export const useDrafts = createChatStore(() => ({ ids: [] }), (store) => store.setState({ ids: Object.keys(storage.read()) }));
21
15
  export function useHasDraft(chatId) {
22
16
  return useDrafts((s) => s.ids.includes(chatId));
23
17
  }
@@ -30,7 +24,7 @@ function trackDraft(chatId, held) {
30
24
  }
31
25
  /* An unsent prompt per chat node, kept across reloads so a half-written message is never lost. */
32
26
  export function readDraft(chatId) {
33
- const record = readAll()[chatId];
27
+ const record = storage.read()[chatId];
34
28
  return record
35
29
  ? {
36
30
  text: record.text,
@@ -43,11 +37,11 @@ export function readDraft(chatId) {
43
37
  : EMPTY_DRAFT;
44
38
  }
45
39
  export function writeDraft(chatId, draft) {
46
- const drafts = readAll();
40
+ const drafts = storage.read();
47
41
  trackDraft(chatId, !isEmptyDraft(draft));
48
42
  if (isEmptyDraft(draft)) {
49
43
  delete drafts[chatId];
50
- store(drafts);
44
+ storage.write(drafts);
51
45
  return;
52
46
  }
53
47
  drafts[chatId] = {
@@ -58,12 +52,12 @@ export function writeDraft(chatId, draft) {
58
52
  attachments: draft.attachments,
59
53
  quote: draft.quote
60
54
  };
61
- if (store(drafts)) {
55
+ if (storage.write(drafts)) {
62
56
  return;
63
57
  }
64
58
  // A file can outgrow the storage quota; the text is the part worth keeping then.
65
59
  drafts[chatId] = { text: draft.text, mentions: draft.mentions, skills: draft.skills, chats: draft.chats, quote: draft.quote };
66
- store(drafts);
60
+ storage.write(drafts);
67
61
  }
68
62
  /* Text handed to a draft from outside the composer goes under what was already typed, never over it. */
69
63
  export function joinDraftText(current, added) {
@@ -144,7 +144,7 @@ function styleOf(state) {
144
144
  }
145
145
  return style;
146
146
  }
147
- export function parseAnsi(text, options = {}) {
147
+ function styledTokens(text) {
148
148
  const tokens = [];
149
149
  let state = {};
150
150
  let position = 0;
@@ -160,6 +160,10 @@ export function parseAnsi(text, options = {}) {
160
160
  if (position < text.length) {
161
161
  tokens.push({ content: text.slice(position), style: styleOf(state) });
162
162
  }
163
+ return tokens;
164
+ }
165
+ export function parseAnsi(text, options = {}) {
166
+ const tokens = styledTokens(text);
163
167
  const plain = tokens.map((token) => token.content).join('');
164
168
  let start = 0;
165
169
  let end = plain.length;
@@ -121,20 +121,19 @@ export function forkShapes(origin) {
121
121
  return origin === 'view' ? ['view'] : ['node', 'view'];
122
122
  }
123
123
  export function forkPayload(input) {
124
+ const { cli, worktree } = input;
125
+ const provider = cli !== undefined && cli.chosen.provider !== cli.original.provider ? cli.chosen.provider : null;
126
+ const selection = cli !== undefined && (provider !== null || cli.chosen.selection.model !== cli.original.selection.model) ? cli.chosen.selection : null;
127
+ const account = cli?.chosen.account !== undefined && (provider !== null || cli.chosen.account !== cli.original.account) ? cli.chosen.account : null;
124
128
  return {
125
129
  chatId: input.chatId,
126
130
  turnId: input.turnId,
127
131
  title: input.title,
128
132
  ...(input.shape === 'view' ? { asView: true } : {}),
129
- ...(input.cli && input.cli.chosen.provider !== input.cli.original.provider ? { provider: input.cli.chosen.provider } : {}),
130
- ...(input.cli && (input.cli.chosen.provider !== input.cli.original.provider || input.cli.chosen.selection.model !== input.cli.original.selection.model)
131
- ? { selection: input.cli.chosen.selection }
132
- : {}),
133
- ...(input.cli?.chosen.account !== undefined &&
134
- (input.cli.chosen.provider !== input.cli.original.provider || input.cli.chosen.account !== input.cli.original.account)
135
- ? { account: input.cli.chosen.account }
136
- : {}),
137
- ...(input.worktree ? { worktree: { branch: input.worktree.branch }, ...(input.worktree.filesAfterTurn ? { filesAfterTurn: true } : {}) } : {})
133
+ ...(provider === null ? {} : { provider }),
134
+ ...(selection === null ? {} : { selection }),
135
+ ...(account === null ? {} : { account }),
136
+ ...(worktree ? { worktree: { branch: worktree.branch }, ...(worktree.filesAfterTurn ? { filesAfterTurn: true } : {}) } : {})
138
137
  };
139
138
  }
140
139
  /* Why a branch cannot be the fork's, or null when it can. */
@@ -17,18 +17,21 @@ export function limitView(info, now) {
17
17
  detail: resumes === null ? i18next.t('agent-chat:limit.overload.sendAgain') : i18next.t('agent-chat:limit.overload.retries', { time: resumes })
18
18
  };
19
19
  }
20
- const resets = limit.resetsAt === undefined ? null : formatMoment(limit.resetsAt, now);
21
- return {
22
- pill: resumes !== null
23
- ? i18next.t('agent-chat:limit.usage.pillResumes', { time: resumes })
24
- : resets !== null
25
- ? i18next.t('agent-chat:limit.usage.pillUntil', { time: resets })
26
- : i18next.t('agent-chat:limit.usage.pill'),
27
- title: i18next.t('agent-chat:limit.usage.title'),
28
- detail: resumes !== null
29
- ? i18next.t('agent-chat:limit.usage.resumes', { time: resumes })
30
- : resets !== null
31
- ? i18next.t('agent-chat:limit.usage.resets', { time: resets })
32
- : null
33
- };
20
+ const title = i18next.t('agent-chat:limit.usage.title');
21
+ if (resumes !== null) {
22
+ return {
23
+ pill: i18next.t('agent-chat:limit.usage.pillResumes', { time: resumes }),
24
+ title,
25
+ detail: i18next.t('agent-chat:limit.usage.resumes', { time: resumes })
26
+ };
27
+ }
28
+ if (limit.resetsAt !== undefined) {
29
+ const resets = formatMoment(limit.resetsAt, now);
30
+ return {
31
+ pill: i18next.t('agent-chat:limit.usage.pillUntil', { time: resets }),
32
+ title,
33
+ detail: i18next.t('agent-chat:limit.usage.resets', { time: resets })
34
+ };
35
+ }
36
+ return { pill: i18next.t('agent-chat:limit.usage.pill'), title, detail: null };
34
37
  }
@@ -6,18 +6,9 @@ export const RESUME_COMPACTION_TOKENS = 100_000;
6
6
  /* The longest prompt cache Claude keeps is an hour, so past this the whole thread is read again anyway. */
7
7
  export const RESUME_COMPACTION_IDLE_MS = 70 * 60_000;
8
8
  function lastTurn(items) {
9
- for (let i = items.length - 1; i >= 0; i--) {
10
- const item = items[i];
11
- if (item.kind === 'turn') {
12
- return item;
13
- }
14
- }
15
- return null;
9
+ return items.findLast((item) => item.kind === 'turn') ?? null;
16
10
  }
17
- /*
18
- * Whether to offer compacting before the next message. The caller passes `now`, so this stays pure
19
- * and the test needs no clock.
20
- */
11
+ /* Whether to offer compacting before the next message. */
21
12
  export function resumeCompactionOffer({ info, compaction, items, dismissedTurnId, now }) {
22
13
  if (compaction === undefined || compaction === 'none') {
23
14
  return null;
@@ -76,20 +76,7 @@ export function tickOfRow(ticks, rowIndex) {
76
76
  if (ticks.length === 0) {
77
77
  return null;
78
78
  }
79
- let low = 0;
80
- let high = ticks.length - 1;
81
- let found = 0;
82
- while (low <= high) {
83
- const middle = (low + high) >> 1;
84
- if (ticks[middle].rowIndex <= rowIndex) {
85
- found = middle;
86
- low = middle + 1;
87
- }
88
- else {
89
- high = middle - 1;
90
- }
91
- }
92
- return found;
79
+ return Math.max(0, lastIndexAtOrBelow(ticks.length, (index) => ticks[index].rowIndex, rowIndex));
93
80
  }
94
81
  /* Per tick, whether any of the rows has a find hit in its stretch. */
95
82
  export function ticksWithHits(ticks, hitRows) {
@@ -146,14 +133,14 @@ export function tickWidth(kind, distanceFromPointer) {
146
133
  }
147
134
  return kind === 'wake' ? WIDTH_WAKE_PX : WIDTH_PERSON_PX;
148
135
  }
149
- /* The last index whose start lies at or above a line, or -1. The starts only grow. */
150
- function lastAtOrAbove(starts, line) {
136
+ /* The last index whose value is at most `limit`, or -1. The values only grow. */
137
+ function lastIndexAtOrBelow(length, valueAt, limit) {
151
138
  let low = 0;
152
- let high = starts.length - 1;
139
+ let high = length - 1;
153
140
  let found = -1;
154
141
  while (low <= high) {
155
142
  const middle = (low + high) >> 1;
156
- if (starts[middle] <= line) {
143
+ if (valueAt(middle) <= limit) {
157
144
  found = middle;
158
145
  low = middle + 1;
159
146
  }
@@ -163,6 +150,10 @@ function lastAtOrAbove(starts, line) {
163
150
  }
164
151
  return found;
165
152
  }
153
+ /* The last index whose start lies at or above a line, or -1. */
154
+ function lastAtOrAbove(starts, line) {
155
+ return lastIndexAtOrBelow(starts.length, (index) => starts[index], line);
156
+ }
166
157
  /*
167
158
  * The messages on screen. A message counts from its own start to the start of the next one, so the
168
159
  * question whose answer fills the screen is in view even after it scrolled out at the top.
@@ -21,10 +21,9 @@ export function summarizeGroup(tools) {
21
21
  if (only !== null && toolEntry(only)?.grouped === true) {
22
22
  return i18next.t(`agent-chat:group.tools.${only}`, { count: tools.length });
23
23
  }
24
- /* A run that only touches files says so once, whether it took one tool or three, and counts the
25
- files rather than the calls: three edits to one file are one file edited. */
24
+ // Counts the files rather than the calls: three edits to one file are one file edited.
26
25
  if (tools.every((tool) => isFileChange(tool.name))) {
27
- const paths = new Set(tools.map((tool) => tool.input?.file_path ?? tool.id));
26
+ const paths = new Set(tools.map(editedPath));
28
27
  return i18next.t('agent-chat:group.edited', { count: paths.size });
29
28
  }
30
29
  if (only !== null) {
@@ -32,6 +31,9 @@ export function summarizeGroup(tools) {
32
31
  }
33
32
  return i18next.t('agent-chat:group.toolCalls', { count: tools.length });
34
33
  }
34
+ function editedPath(tool) {
35
+ return tool.input?.file_path ?? tool.id;
36
+ }
35
37
  /*
36
38
  * What a folded turn did, a sentence per kind of call in the order it first came: "Read 11 files",
37
39
  * "Searched 2 patterns". Every file change reads as one sentence, and calls without a sentence of
@@ -44,7 +46,7 @@ export function summarizeTurn(tools) {
44
46
  let other = 0;
45
47
  for (const tool of tools) {
46
48
  if (isFileChange(tool.name)) {
47
- edited.add(tool.input?.file_path ?? tool.id);
49
+ edited.add(editedPath(tool));
48
50
  counts.set('agent-chat:group.edited', edited.size);
49
51
  }
50
52
  else if (toolEntry(tool.name)?.grouped === true) {
@@ -265,11 +267,8 @@ function lastAssistantRow(rows) {
265
267
  }
266
268
  return null;
267
269
  }
268
- /* The whole thread as rows; items without a turn (older records) are shown as they are. */
269
- export function deriveTimelineRows(items, options) {
270
- const rows = [];
271
- const children = groupChildren(items);
272
- // Items are grouped by turn, in order of first appearance; runs of turnless items keep their place.
270
+ /* Items grouped by turn, in order of first appearance; runs of turnless items keep their place. */
271
+ function chunksByTurn(items) {
273
272
  const chunks = [];
274
273
  const byTurn = new Map();
275
274
  for (const item of items) {
@@ -291,7 +290,13 @@ export function deriveTimelineRows(items, options) {
291
290
  }
292
291
  bucket.push(item);
293
292
  }
294
- for (const chunk of chunks) {
293
+ return chunks;
294
+ }
295
+ /* The whole thread as rows; items without a turn (older records) are shown as they are. */
296
+ export function deriveTimelineRows(items, options) {
297
+ const rows = [];
298
+ const children = groupChildren(items);
299
+ for (const chunk of chunksByTurn(items)) {
295
300
  if (chunk.turnId === null) {
296
301
  rows.push(...rowsForItems(chunk.items, options, children));
297
302
  continue;
@@ -10,10 +10,9 @@ function entry(icon, summary, rest = {}) {
10
10
  };
11
11
  }
12
12
  /*
13
- * The icon, the summary line, the group sentence and the file-changing flag of every tool the thread
14
- * has a word for, in one table, because they used to sit in four and a name added to one of them was
15
- * missing from the rest. A name that is not here still draws, reads and groups; it falls back to the
16
- * wrench and to the first string of its input.
13
+ * Every tool the thread has a word for, in one table so a new name is never missing from a lookup. A
14
+ * name that is not here still draws, reads and groups; it falls back to the wrench and to the first
15
+ * string of its input.
17
16
  */
18
17
  export const TOOL_CATALOG = {
19
18
  Read: entry(Eye, ['file_path'], { readsImage: true }),
@@ -1,5 +1,7 @@
1
- /* What a file dragged out of an app's own list of files carries: the paths, space separated, relative
2
- to the folder. The composer takes it next to the image files it already accepts. */
1
+ /*
2
+ * What a file dragged out of an app's own list of files carries: the paths, space separated, relative
3
+ * to the folder. The composer takes it next to the image files it already accepts.
4
+ */
3
5
  export const MENTION_DRAG_TYPE = 'application/x-adecore-mention';
4
6
  export const LEGACY_MENTION_DRAG_TYPE = 'application/x-ruimte-mention';
5
7
  export const MENTION_DRAG_TYPES = [MENTION_DRAG_TYPE, LEGACY_MENTION_DRAG_TYPE];
@@ -110,10 +112,7 @@ export function presentMentions(text, chosen) {
110
112
  export function presentSkills(text, chosen) {
111
113
  return present(text, '$', chosen);
112
114
  }
113
- /*
114
- * The characters a segment stands for, sigil included. Joined, the segments spell the text they
115
- * were cut from.
116
- */
115
+ /* The characters a segment stands for, sigil included; joined, the segments spell the text they were cut from. */
117
116
  export function chipText(segment) {
118
117
  if (segment.kind === 'mention') {
119
118
  return `@${segment.path}`;
@@ -1,10 +1,8 @@
1
1
  import i18next from 'i18next';
2
2
  import { RuntimeModeSchema } from '@adecore/agent-contracts';
3
3
  /*
4
- * The runtime modes in the order they are offered, shared by the composer's mode picker, the
5
- * settings dialog and the agent menus. That order is the schema's, which the host reads as the
6
- * rank from narrowest to widest. Only the ids live here: the words are in `modes.<id>` of the `chat`
7
- * namespace, read when a surface draws one rather than when this module loads.
4
+ * The runtime modes in the order they are offered: the schema's, which the host reads as the rank
5
+ * from narrowest to widest. Their words are read when a surface draws one, not when this module loads.
8
6
  */
9
7
  export const RUNTIME_MODES = RuntimeModeSchema.options;
10
8
  export function runtimeModeLabel(mode) {
@@ -69,9 +69,7 @@ export function stashedFrom(draft, id, now) {
69
69
  };
70
70
  }
71
71
  const storage = persistedJson(storageKey, parseStash, [], () => chatStorageLegacyKeys('stash'));
72
- /*
73
- * Prompts put aside for later can move to another chat, so the shelf is shared across chats.
74
- */
72
+ /* Prompts put aside for later can move to another chat, so the shelf is shared across chats. */
75
73
  export const useStash = createChatStore(() => ({ prompts: [] }), (store) => store.setState({ prompts: storage.read() }));
76
74
  function write(prompts) {
77
75
  useStash.setState({ prompts });
@@ -30,10 +30,9 @@ function dataUrlOf(blob) {
30
30
  });
31
31
  }
32
32
  /*
33
- * A still of the middle of an image, decoded once. The composer used to draw the full data URL in a
34
- * 56 px box, which a browser decodes again for every paint, so a tall screenshot made typing stutter.
35
- * An animated image gives its first frame. Whatever cannot be decoded this way (an SVG, a browser
36
- * without `OffscreenCanvas`) falls back to the image itself, which is what it was before.
33
+ * A still of the middle of an image, decoded once: a browser decodes a full data URL again on every
34
+ * paint, so a tall screenshot made typing stutter. An animated image gives its first frame; what
35
+ * cannot be decoded this way (an SVG, a browser without `OffscreenCanvas`) falls back to the image.
37
36
  */
38
37
  async function makeThumbnail(upload) {
39
38
  try {
@@ -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
  const scrollers = new Map();
4
+ /* The timeline owns following; the composer subscribes here to show its jump-to-end button. */
5
+ const ends = new Map();
6
+ const listeners = new Set();
4
7
  /* The timeline hands its scroller over so the composer, which never sees it, can page through it. */
5
8
  export function registerTimeline(chatId, element, follow, coveredHeight) {
6
9
  if (element === null) {
@@ -14,9 +17,6 @@ export function registerTimeline(chatId, element, follow, coveredHeight) {
14
17
  }
15
18
  };
16
19
  }
17
- /* The timeline owns following; the composer subscribes here to show its jump-to-end button. */
18
- const ends = new Map();
19
- const listeners = new Set();
20
20
  export function setTimelineAtEnd(chatId, atEnd) {
21
21
  if (ends.get(chatId) === atEnd) {
22
22
  return;
@@ -73,59 +73,53 @@ export function stepTimelineMessage(key, direction) {
73
73
  return steppers.get(key)?.(direction) ?? false;
74
74
  }
75
75
  /*
76
- * A jump to one message of a chat, asked from outside its thread (the chat's menu). A thread that is
77
- * not on screen yet gets it when it registers, since the menu may have to show the chat first.
78
- * Keyed with the scope's `keyOf`, like the steppers.
76
+ * Jumps asked from outside a thread (the chat's menu, a card that names a turn), keyed with the
77
+ * scope's `keyOf` like the steppers. A thread that is not on screen yet gets its jump when it
78
+ * registers, since the asker may have to show the chat first.
79
79
  */
80
- const jumpers = new Map();
81
- const waitingJumps = new Map();
82
- export function registerItemJumper(key, jump) {
83
- jumpers.set(key, jump);
84
- const waiting = waitingJumps.get(key);
85
- if (waiting !== undefined) {
86
- waitingJumps.delete(key);
87
- jump(waiting);
88
- }
89
- return () => {
90
- if (jumpers.get(key) === jump) {
91
- jumpers.delete(key);
80
+ function waitingJumps() {
81
+ const jumpers = new Map();
82
+ const waiting = new Map();
83
+ return {
84
+ register(key, jump) {
85
+ jumpers.set(key, jump);
86
+ if (waiting.has(key)) {
87
+ const target = waiting.get(key);
88
+ waiting.delete(key);
89
+ jump(target);
90
+ }
91
+ return () => {
92
+ if (jumpers.get(key) === jump) {
93
+ jumpers.delete(key);
94
+ }
95
+ };
96
+ },
97
+ jump(key, target) {
98
+ const jump = jumpers.get(key);
99
+ if (jump === undefined) {
100
+ waiting.set(key, target);
101
+ return false;
102
+ }
103
+ jump(target);
104
+ return true;
92
105
  }
93
106
  };
94
107
  }
108
+ const itemJumps = waitingJumps();
109
+ export function registerItemJumper(key, jump) {
110
+ return itemJumps.register(key, jump);
111
+ }
95
112
  /* False when that chat has no thread on screen; the jump then waits for the next one that registers. */
96
113
  export function jumpToTimelineItem(key, itemId) {
97
- const jump = jumpers.get(key);
98
- if (jump === undefined) {
99
- waitingJumps.set(key, itemId);
100
- return false;
101
- }
102
- jump(itemId);
103
- return true;
114
+ return itemJumps.jump(key, itemId);
104
115
  }
105
- const turnJumpers = new Map();
106
- const waitingTurnJumps = new Map();
116
+ const turnJumps = waitingJumps();
107
117
  export function registerTurnJumper(key, jump) {
108
- turnJumpers.set(key, jump);
109
- const waiting = waitingTurnJumps.get(key);
110
- if (waiting !== undefined) {
111
- waitingTurnJumps.delete(key);
112
- jump(waiting.turnId, waiting.target);
113
- }
114
- return () => {
115
- if (turnJumpers.get(key) === jump) {
116
- turnJumpers.delete(key);
117
- }
118
- };
118
+ return turnJumps.register(key, ({ turnId, target }) => jump(turnId, target));
119
119
  }
120
120
  /* False when that chat has no thread on screen; the jump then waits for the next one that registers. */
121
121
  export function jumpToTimelineTurn(key, turnId, target) {
122
- const jump = turnJumpers.get(key);
123
- if (jump === undefined) {
124
- waitingTurnJumps.set(key, { turnId, target });
125
- return false;
126
- }
127
- jump(turnId, target);
128
- return true;
122
+ return turnJumps.jump(key, { turnId, target });
129
123
  }
130
124
  /* How near the top, in screens, the page before is asked for, so it is there before a reader reaches the edge. */
131
125
  const EARLIER_SCREENS = 1.5;
@@ -9,11 +9,7 @@ import { chatHost } from '../../host.js';
9
9
  import { useChatScope } from '../../scope.js';
10
10
  import { useChatRow } from '../../state/chats.js';
11
11
  import { Icon, Menu } from '@adecore/ui';
12
- /*
13
- * "Bookmarks" in the menu of a chat node or a chat view: the chat's bookmarks in the order of the
14
- * thread, each a jump to its message, with rename and remove on the row. A chat without bookmarks
15
- * draws nothing.
16
- */
12
+ /* A chat's bookmarks in thread order, each a jump to its message with rename and remove; nothing without bookmarks. */
17
13
  export function BookmarkSubmenu({ chatId }) {
18
14
  const { t } = useTranslation('agent-chat');
19
15
  const scope = useChatScope();
@@ -102,9 +102,8 @@ const CodeLine = memo(function CodeLine({ tokens }) {
102
102
  });
103
103
  /*
104
104
  * A fenced block, highlighted a line at a time. Until shiki and the grammar are there it holds its
105
- * place invisibly in the same shape, so there is never a frame of bare code that turns colored. A
106
- * fence that is still open is tokenized as it grows, and the same component carries on once it
107
- * closes, so closing it does not draw it again.
105
+ * place invisibly, so bare code never turns colored. An open fence is tokenized as it grows, and
106
+ * closing it does not draw it again.
108
107
  */
109
108
  export function CodeBlock({ code, lang, actions }) {
110
109
  const { t } = useTranslation('agent-chat');