@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
@@ -28,10 +28,9 @@ function personActsIn(frame: HTMLIFrameElement): boolean {
28
28
  }
29
29
 
30
30
  /*
31
- * One visual's page in a frame on the thread's own ground. The frame loads the app's sandbox host
32
- * page and the bridge hands it the page and the theme. The frame never gets `allow-same-origin`,
33
- * which keeps the page on an opaque origin even when the host page is served on the app's own. Its
34
- * box keeps one height while the page loads and when it fails, so nothing under it moves.
31
+ * One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
32
+ * the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
33
+ * when the host page is served on the app's own. Its box keeps one height while it loads or fails.
35
34
  */
36
35
  export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
37
36
  const { t } = useTranslation('agent-chat');
@@ -60,25 +60,35 @@ export function enterAction(keys: EnterKeys, { inOpenFence, list, column }: Ente
60
60
  return list.empty ? 'leave-list' : 'continue-list';
61
61
  }
62
62
 
63
- /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
64
- export function inOpenFence(tree: Tree, pos: number): boolean {
63
+ /* The innermost fenced code block around `pos`, if any. */
64
+ function enclosingFence(tree: Tree, pos: number): SyntaxNode | null {
65
65
  for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
66
66
  if (node.name === 'FencedCode') {
67
- return pos > node.from && node.getChildren('CodeMark').length < 2;
67
+ return node;
68
68
  }
69
69
  }
70
- return false;
70
+ return null;
71
+ }
72
+
73
+ /* A fence nobody has closed yet has its opening mark only. */
74
+ function isOpenFence(fence: SyntaxNode): boolean {
75
+ return fence.getChildren('CodeMark').length < 2;
76
+ }
77
+
78
+ /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
79
+ export function inOpenFence(tree: Tree, pos: number): boolean {
80
+ const fence = enclosingFence(tree, pos);
81
+ return fence !== null && pos > fence.from && isOpenFence(fence);
71
82
  }
72
83
 
73
84
  /* Whether `pos` sits in the body of a fenced code block, open or closed: below its opening line and before its end. */
74
85
  export function inFenceBody(tree: Tree, text: string, pos: number): boolean {
75
- for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
76
- if (node.name === 'FencedCode') {
77
- const openingEnd = text.indexOf('\n', node.from);
78
- return openingEnd !== -1 && pos > openingEnd && (pos < node.to || node.getChildren('CodeMark').length < 2);
79
- }
86
+ const fence = enclosingFence(tree, pos);
87
+ if (fence === null) {
88
+ return false;
80
89
  }
81
- return false;
90
+ const openingEnd = text.indexOf('\n', fence.from);
91
+ return openingEnd !== -1 && pos > openingEnd && (pos < fence.to || isOpenFence(fence));
82
92
  }
83
93
 
84
94
  /* The spaces Tab inserts at `column`, up to the next stop of four, the way a code editor lines up. */
@@ -97,7 +107,7 @@ export function inCode(tree: Tree, text: string, pos: number): boolean {
97
107
  let blockFrom: number | null = null;
98
108
  for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
99
109
  if (node.name === 'FencedCode') {
100
- return pos > node.from && (pos < node.to || node.getChildren('CodeMark').length < 2);
110
+ return pos > node.from && (pos < node.to || isOpenFence(node));
101
111
  }
102
112
  if (node.name === 'InlineCode' && pos > node.from && pos < node.to) {
103
113
  return true;
@@ -1,9 +1,9 @@
1
1
  import { createContext, type ReactNode } from 'react';
2
2
  import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
-
4
- export type { UiLinkTarget } from '@adecore/intelligent-ui/links';
5
3
  import type { UiLinkTarget } from '@adecore/intelligent-ui/links';
6
4
 
5
+ export type { UiLinkTarget };
6
+
7
7
  /* How the host lets a link be drawn: a chip it can open, or plain text it cannot. */
8
8
  export interface UiLink {
9
9
  state: 'chip' | 'plain';
@@ -1,6 +1,6 @@
1
1
  import { chipText, tokenizeChips, type ChipSegment } from '../mentions';
2
2
 
3
- /* Just the part of hast this plugin touches; the package's own types are not a dependency of the client. */
3
+ /* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
4
4
  interface HastText {
5
5
  type: 'text';
6
6
  value: string;
@@ -1,4 +1,4 @@
1
- /* Just the part of hast this plugin touches; the package's own types are not a dependency of the client. */
1
+ /* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
2
2
  interface HastText {
3
3
  type: 'text';
4
4
  value: string;
@@ -1,4 +1,4 @@
1
- /* Just the part of mdast this plugin touches; the package's own types are not a dependency of the client. */
1
+ /* Just the part of mdast this plugin touches; the package's own types are not a dependency here. */
2
2
  interface MdastNode {
3
3
  type: string;
4
4
  value?: string;
@@ -391,9 +391,8 @@ function OpenChatButton({ chatId }: { chatId: string }) {
391
391
  }
392
392
 
393
393
  /*
394
- * The header of a turn the agent started itself, in place of the message of the person that is
395
- * missing. It is one line: what the sub-agent came back with lives on that agent's own row, behind
396
- * "Show result". When the CLI named the sub-agent it woke up about, the header opens that row.
394
+ * The header of a turn the agent started itself, in place of a message of the person. What the
395
+ * sub-agent came back with lives on its own row, which the header opens when the CLI named it.
397
396
  */
398
397
  export function AgentTurnRow({ label, onOpen }: { label: string; onOpen?: () => void }) {
399
398
  const line = 'mb-0.5 flex h-7 w-full items-center gap-2 text-left text-xs text-text-muted';
@@ -430,7 +429,7 @@ export function CompactionRow({ preTokens }: { preTokens: number | null }) {
430
429
  );
431
430
  }
432
431
 
433
- const DECISION_ICON: Record<Exclude<ChatApprovalItem['decision'], 'pending'>, React.ReactNode> = {
432
+ const DECISION_ICON: Record<Exclude<ChatApprovalItem['decision'], 'pending'>, ReactNode> = {
434
433
  allow: <Icon icon={Check} size={12} />,
435
434
  'allow-always': <Icon icon={Check} size={12} />,
436
435
  deny: <Icon icon={X} size={12} />,
@@ -1,4 +1,4 @@
1
- import { Suspense, useEffect, useState } from 'react';
1
+ import { Suspense, useEffect, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import i18next from 'i18next';
4
4
  import { useTranslation } from 'react-i18next';
@@ -40,14 +40,14 @@ export function ToggleLine({
40
40
  trailing,
41
41
  className
42
42
  }: {
43
- icon: React.ReactNode;
43
+ icon: ReactNode;
44
44
  label: string;
45
45
  detail?: string;
46
46
  open: boolean;
47
47
  onToggle(): void;
48
48
  failed?: boolean;
49
49
  live?: boolean;
50
- trailing?: React.ReactNode;
50
+ trailing?: ReactNode;
51
51
  className?: string;
52
52
  }) {
53
53
  return (
@@ -75,24 +75,60 @@ export function ToggleLine({
75
75
  );
76
76
  }
77
77
 
78
- function ToolBody({ tool }: { tool: ChatToolItem }) {
78
+ /* The diff renderers load lazily, so a diff waits on them under a line that says so. */
79
+ function DiffSuspense({ children }: { children: ReactNode }) {
79
80
  const { t } = useTranslation('agent-chat');
81
+ return <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>{children}</Suspense>;
82
+ }
83
+
84
+ /* A file in a changed files card, which opens its diff under it. */
85
+ function ChangedFileLine({
86
+ path,
87
+ open,
88
+ onToggle,
89
+ trailing,
90
+ children
91
+ }: {
92
+ path: string;
93
+ open: boolean;
94
+ onToggle(): void;
95
+ trailing: ReactNode;
96
+ children: ReactNode;
97
+ }) {
98
+ return (
99
+ <div className="border-t border-border">
100
+ <button
101
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover"
102
+ data-file-path={path}
103
+ onClick={onToggle}
104
+ >
105
+ <Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90')} />
106
+ <span className="min-w-0 truncate font-mono text-text">{path}</span>
107
+ <span className="grow" />
108
+ {trailing}
109
+ </button>
110
+ {open && <div className="border-t border-border">{children}</div>}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ function ToolBody({ tool }: { tool: ChatToolItem }) {
80
116
  const patches = unifiedChanges(tool);
81
117
  const changes = patches.length > 0 ? [] : fileChanges(tool.name, tool.input);
82
118
  return (
83
119
  <div className="mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised">
84
120
  {patches.length > 0 ? (
85
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
121
+ <DiffSuspense>
86
122
  {patches.map((change, index) => (
87
123
  <UnifiedDiff key={index} change={change} />
88
124
  ))}
89
- </Suspense>
125
+ </DiffSuspense>
90
126
  ) : changes.length > 0 ? (
91
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
127
+ <DiffSuspense>
92
128
  {changes.map((change, index) => (
93
129
  <EditDiff key={index} change={change} />
94
130
  ))}
95
- </Suspense>
131
+ </DiffSuspense>
96
132
  ) : (
97
133
  <pre className="max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text">
98
134
  {clip(JSON.stringify(tool.input, null, 2) ?? '')}
@@ -113,9 +149,10 @@ function ToolBody({ tool }: { tool: ChatToolItem }) {
113
149
  );
114
150
  }
115
151
 
116
- /* One settled tool call: a line, and its input and output behind it. An image the call looked at
117
- is drawn under the line, because a picture says more about that read than its path does.
118
- `icon`, `label` and `failed` let a row that knows the call better name it in its own words. */
152
+ /*
153
+ * One settled tool call: a line, and its input and output behind it. An image the call looked at is
154
+ * drawn under the line. `icon`, `label` and `failed` let a row that knows the call better name it.
155
+ */
119
156
  export function WorkRow({
120
157
  tool,
121
158
  nested,
@@ -127,7 +164,7 @@ export function WorkRow({
127
164
  tool: ChatToolItem;
128
165
  nested?: boolean;
129
166
  detail?: string;
130
- icon?: React.ReactNode;
167
+ icon?: ReactNode;
131
168
  label?: string;
132
169
  failed?: boolean;
133
170
  }) {
@@ -235,14 +272,13 @@ function omittedLabel(reason: 'binary' | 'too-large'): string {
235
272
 
236
273
  /* One file of a turn's checkpoint diff: its patch, or the reason there is none. */
237
274
  function CheckpointFileBody({ file }: { file: ChatCheckpointFile }) {
238
- const { t } = useTranslation('agent-chat');
239
275
  if (file.omitted) {
240
276
  return <div className="px-3 py-2 text-xs text-text-faint">{omittedLabel(file.omitted)}</div>;
241
277
  }
242
278
  return (
243
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
279
+ <DiffSuspense>
244
280
  <UnifiedDiff change={file} />
245
- </Suspense>
281
+ </DiffSuspense>
246
282
  );
247
283
  }
248
284
 
@@ -261,11 +297,11 @@ function ChangedFilesCard({
261
297
  deleted?: number;
262
298
  /* A jump from outside the chat that asked for this card; each new one opens it. */
263
299
  revealNonce?: number;
264
- children: React.ReactNode;
300
+ children: ReactNode;
265
301
  }) {
266
302
  const { t } = useTranslation('agent-chat');
267
303
  const [expanded, setExpanded] = useState(false);
268
- // A jump from outside opens the card, also when the jump put the row on screen and so mounted it; the flash is gone by the time the row is scrolled to again.
304
+ // A jump opens the card, also one that mounted it by scrolling it on screen; the flash is gone by the next visit.
269
305
  const [seenReveal, setSeenReveal] = useState(0);
270
306
  if (revealNonce > 0 && revealNonce !== seenReveal) {
271
307
  setSeenReveal(revealNonce);
@@ -345,28 +381,20 @@ export function ChangedFilesRow({
345
381
  deleted={files.reduce((sum, file) => sum + file.deleted, 0)}
346
382
  >
347
383
  {files.map((file) => (
348
- <div key={file.path} className="border-t border-border">
349
- <button
350
- className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover"
351
- data-file-path={file.path}
352
- onClick={() => setOpen((o) => ({ ...o, [file.path]: !o[file.path] }))}
353
- >
354
- <Icon
355
- icon={ChevronRight}
356
- size={12}
357
- className={clsx('shrink-0 text-text-faint transition-transform', open[file.path] && 'rotate-90')}
358
- />
359
- <span className="min-w-0 truncate font-mono text-text">{file.path}</span>
360
- <span className="grow" />
361
- <span className="text-chat-added tabular-nums">+{file.added}</span>
362
- <span className="text-chat-removed tabular-nums">-{file.deleted}</span>
363
- </button>
364
- {open[file.path] && (
365
- <div className="border-t border-border">
366
- <CheckpointFileBody file={file} />
367
- </div>
368
- )}
369
- </div>
384
+ <ChangedFileLine
385
+ key={file.path}
386
+ path={file.path}
387
+ open={open[file.path] === true}
388
+ onToggle={() => setOpen((o) => ({ ...o, [file.path]: !o[file.path] }))}
389
+ trailing={
390
+ <>
391
+ <span className="text-chat-added tabular-nums">+{file.added}</span>
392
+ <span className="text-chat-removed tabular-nums">-{file.deleted}</span>
393
+ </>
394
+ }
395
+ >
396
+ <CheckpointFileBody file={file} />
397
+ </ChangedFileLine>
370
398
  ))}
371
399
  </ChangedFilesCard>
372
400
  );
@@ -384,7 +412,7 @@ function ProviderChangedFiles({
384
412
  tools: ChatToolItem[];
385
413
  open: Record<string, boolean>;
386
414
  revealNonce: number;
387
- setOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
415
+ setOpen: Dispatch<SetStateAction<Record<string, boolean>>>;
388
416
  }) {
389
417
  const { t } = useTranslation('agent-chat');
390
418
  const byPath = new Map<string, { edits: FileChange[]; patches: ChatFileChange[] }>();
@@ -411,35 +439,24 @@ function ProviderChangedFiles({
411
439
  }
412
440
  return (
413
441
  <ChangedFilesCard count={byPath.size} revealNonce={revealNonce}>
414
- {[...byPath].map(([path, entry]) => {
415
- const count = entry.edits.length + entry.patches.length;
416
- return (
417
- <div key={path} className="border-t border-border">
418
- <button
419
- className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover"
420
- data-file-path={path}
421
- onClick={() => setOpen((o) => ({ ...o, [path]: !o[path] }))}
422
- >
423
- <Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', open[path] && 'rotate-90')} />
424
- <span className="min-w-0 truncate font-mono text-text">{path}</span>
425
- <span className="grow" />
426
- <span className="text-text-faint">{t('work.edits', { count })}</span>
427
- </button>
428
- {open[path] && (
429
- <div className="border-t border-border">
430
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
431
- {entry.patches.map((change, index) => (
432
- <UnifiedDiff key={`patch-${index}`} change={change} />
433
- ))}
434
- {entry.edits.map((change, index) => (
435
- <EditDiff key={`edit-${index}`} change={change} />
436
- ))}
437
- </Suspense>
438
- </div>
439
- )}
440
- </div>
441
- );
442
- })}
442
+ {[...byPath].map(([path, entry]) => (
443
+ <ChangedFileLine
444
+ key={path}
445
+ path={path}
446
+ open={open[path] === true}
447
+ onToggle={() => setOpen((o) => ({ ...o, [path]: !o[path] }))}
448
+ trailing={<span className="text-text-faint">{t('work.edits', { count: entry.edits.length + entry.patches.length })}</span>}
449
+ >
450
+ <DiffSuspense>
451
+ {entry.patches.map((change, index) => (
452
+ <UnifiedDiff key={`patch-${index}`} change={change} />
453
+ ))}
454
+ {entry.edits.map((change, index) => (
455
+ <EditDiff key={`edit-${index}`} change={change} />
456
+ ))}
457
+ </DiffSuspense>
458
+ </ChangedFileLine>
459
+ ))}
443
460
  </ChangedFilesCard>
444
461
  );
445
462
  }
@@ -2,7 +2,7 @@ import type { VisualScrollRequest } from '@adecore/agent-contracts/visual';
2
2
  import { visualViewportGeometry } from './logic/visual-viewport';
3
3
  import type { VisualBridge } from './visual-bridge';
4
4
 
5
- /** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
5
+ /* Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
6
6
  export class VisualViewportController {
7
7
  private readonly box: HTMLElement;
8
8
  private readonly viewport: HTMLElement;
@@ -40,11 +40,7 @@ export class VisualViewportController {
40
40
  if (this.disposed) {
41
41
  return;
42
42
  }
43
- const outer = this.scroller.getBoundingClientRect();
44
- const box = this.box.getBoundingClientRect();
45
- // A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
46
- const scale = outer.height / this.scroller.offsetHeight || 1;
47
- const top = (box.top - outer.top) / scale - this.scroller.clientTop;
43
+ const top = this.offsetInScroller(this.scroller.getBoundingClientRect(), this.box.getBoundingClientRect());
48
44
  const available = this.scroller.clientHeight;
49
45
  const geometry = visualViewportGeometry(this.box.offsetHeight, available, top);
50
46
  this.viewport.style.height = `${geometry.height}px`;
@@ -67,8 +63,7 @@ export class VisualViewportController {
67
63
  return;
68
64
  }
69
65
  this.pendingDelta = 0;
70
- const scale = outer.height / this.scroller.offsetHeight || 1;
71
- const start = this.scroller.scrollTop + (box.top - outer.top) / scale - this.scroller.clientTop;
66
+ const start = this.scroller.scrollTop + this.offsetInScroller(outer, box);
72
67
  const top = 'to' in request ? start + request.to : request.edge === 'start' ? 0 : this.scroller.scrollHeight;
73
68
  this.scroller.scrollTo({ top, behavior: 'instant' });
74
69
  this.refresh();
@@ -83,6 +78,13 @@ export class VisualViewportController {
83
78
  this.bridge.setViewport(null);
84
79
  }
85
80
 
81
+ /* The box's top below the scroller's visible top, in the scroller's own pixels. */
82
+ private offsetInScroller(outer: DOMRect, box: DOMRect): number {
83
+ // A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
84
+ const scale = outer.height / this.scroller.offsetHeight || 1;
85
+ return (box.top - outer.top) / scale - this.scroller.clientTop;
86
+ }
87
+
86
88
  private schedule(): void {
87
89
  if (this.frame || this.disposed) {
88
90
  return;
package/src/host.ts CHANGED
@@ -134,7 +134,7 @@ export interface TimelineFind {
134
134
  /* The dictation control beside the composer's editor; it takes its text from the editor itself. */
135
135
  export interface ComposerDictationProps {
136
136
  targetRef: RefObject<HTMLElement | null>;
137
- /* Where the button goes, in the composer's row of round controls. */
137
+ /* In the composer's row of round controls. */
138
138
  buttonContainer: HTMLElement | null | undefined;
139
139
  disabled: boolean;
140
140
  editor(): EditorView | null;
@@ -239,7 +239,6 @@ export interface ChatHost {
239
239
  /* The image a tool read, drawn under its row; null draws none. */
240
240
  ReadImage: ComponentType<{ path: string }> | null;
241
241
  code: {
242
- /* Whether the app is light or dark right now. */
243
242
  useMode(): 'light' | 'dark';
244
243
  /* The Shiki theme ids code is drawn in, one per mode. */
245
244
  useThemes(): { light: string; dark: string };
@@ -300,7 +299,6 @@ export interface ChatHost {
300
299
  };
301
300
  /* Offers a fork of the chat after one of its turns; null offers none. */
302
301
  fork: ((chatId: string, turnId: string) => void) | null;
303
- /* Opens the app's settings on a section: `providers`, or `agents`. */
304
302
  openSettings(section: 'providers' | 'agents'): void;
305
303
  /* Whether the host lets a chat go on by itself once a limit it stopped on lifts. */
306
304
  useResumeAtReset(scopeId: string): boolean;
@@ -110,15 +110,11 @@ export function portTransport(port: FramePort): PortTransport {
110
110
  return promise;
111
111
  },
112
112
  on<E extends AgentEventType>(event: E, handler: (payload: ChatEventMap[E]) => void): () => void {
113
- let handlers = eventHandlers.get(event);
114
- if (!handlers) {
115
- handlers = new Set();
116
- eventHandlers.set(event, handlers);
117
- }
118
- const set = handlers;
119
- set.add(handler);
113
+ const handlers = eventHandlers.get(event) ?? new Set();
114
+ eventHandlers.set(event, handlers);
115
+ handlers.add(handler);
120
116
  return () => {
121
- set.delete(handler);
117
+ handlers.delete(handler);
122
118
  };
123
119
  },
124
120
  subscribeStatus(handler: (status: ChatTransportStatus) => void): () => void {
@@ -194,6 +194,15 @@ export function applyEvent(state: ChatState, event: ChatEvent): ChatState {
194
194
  }
195
195
  }
196
196
 
197
+ /* Fields of a held chat that are not part of its thread; a chat this client does not hold stays as it is. */
198
+ function withFields(store: ChatsStore, key: string, fields: Pick<ChatState, 'bookmarks' | 'visuals'>): Partial<ChatsStore> {
199
+ const current = store.byKey[key];
200
+ if (!current) {
201
+ return {};
202
+ }
203
+ return { byKey: { ...store.byKey, [key]: { ...current, ...fields } } };
204
+ }
205
+
197
206
  export const useChats = create<ChatsStore>((set) => ({
198
207
  byKey: {},
199
208
  statusByKey: {},
@@ -247,22 +256,10 @@ export const useChats = create<ChatsStore>((set) => ({
247
256
  });
248
257
  },
249
258
  bookmarks(key, bookmarks) {
250
- set((s) => {
251
- const current = s.byKey[key];
252
- if (!current) {
253
- return {};
254
- }
255
- return { byKey: { ...s.byKey, [key]: { ...current, bookmarks } } };
256
- });
259
+ set((s) => withFields(s, key, { bookmarks }));
257
260
  },
258
261
  visuals(key, visuals) {
259
- set((s) => {
260
- const current = s.byKey[key];
261
- if (!current) {
262
- return {};
263
- }
264
- return { byKey: { ...s.byKey, [key]: { ...current, visuals } } };
265
- });
262
+ set((s) => withFields(s, key, { visuals }));
266
263
  },
267
264
  forget(key) {
268
265
  set((s) => {