@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
@@ -30,24 +30,32 @@ export function enterAction(keys, { inOpenFence, list, column }) {
30
30
  }
31
31
  return list.empty ? 'leave-list' : 'continue-list';
32
32
  }
33
- /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
34
- export function inOpenFence(tree, pos) {
33
+ /* The innermost fenced code block around `pos`, if any. */
34
+ function enclosingFence(tree, pos) {
35
35
  for (let node = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
36
36
  if (node.name === 'FencedCode') {
37
- return pos > node.from && node.getChildren('CodeMark').length < 2;
37
+ return node;
38
38
  }
39
39
  }
40
- return false;
40
+ return null;
41
+ }
42
+ /* A fence nobody has closed yet has its opening mark only. */
43
+ function isOpenFence(fence) {
44
+ return fence.getChildren('CodeMark').length < 2;
45
+ }
46
+ /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
47
+ export function inOpenFence(tree, pos) {
48
+ const fence = enclosingFence(tree, pos);
49
+ return fence !== null && pos > fence.from && isOpenFence(fence);
41
50
  }
42
51
  /* Whether `pos` sits in the body of a fenced code block, open or closed: below its opening line and before its end. */
43
52
  export function inFenceBody(tree, text, pos) {
44
- for (let node = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
45
- if (node.name === 'FencedCode') {
46
- const openingEnd = text.indexOf('\n', node.from);
47
- return openingEnd !== -1 && pos > openingEnd && (pos < node.to || node.getChildren('CodeMark').length < 2);
48
- }
53
+ const fence = enclosingFence(tree, pos);
54
+ if (fence === null) {
55
+ return false;
49
56
  }
50
- return false;
57
+ const openingEnd = text.indexOf('\n', fence.from);
58
+ return openingEnd !== -1 && pos > openingEnd && (pos < fence.to || isOpenFence(fence));
51
59
  }
52
60
  /* The spaces Tab inserts at `column`, up to the next stop of four, the way a code editor lines up. */
53
61
  export function tabSpaces(column) {
@@ -63,7 +71,7 @@ export function inCode(tree, text, pos) {
63
71
  let blockFrom = null;
64
72
  for (let node = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
65
73
  if (node.name === 'FencedCode') {
66
- return pos > node.from && (pos < node.to || node.getChildren('CodeMark').length < 2);
74
+ return pos > node.from && (pos < node.to || isOpenFence(node));
67
75
  }
68
76
  if (node.name === 'InlineCode' && pos > node.from && pos < node.to) {
69
77
  return true;
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
- export type { UiLinkTarget } from '@adecore/intelligent-ui/links';
4
3
  import type { UiLinkTarget } from '@adecore/intelligent-ui/links';
4
+ export type { UiLinkTarget };
5
5
  export interface UiLink {
6
6
  state: 'chip' | 'plain';
7
7
  label?: string;
@@ -186,9 +186,8 @@ function OpenChatButton({ chatId }) {
186
186
  return (_jsx("button", { className: "text-text-muted hover:text-text", onClick: place.go, children: t('rows.note.openFork') }));
187
187
  }
188
188
  /*
189
- * The header of a turn the agent started itself, in place of the message of the person that is
190
- * missing. It is one line: what the sub-agent came back with lives on that agent's own row, behind
191
- * "Show result". When the CLI named the sub-agent it woke up about, the header opens that row.
189
+ * The header of a turn the agent started itself, in place of a message of the person. What the
190
+ * sub-agent came back with lives on its own row, which the header opens when the CLI named it.
192
191
  */
193
192
  export function AgentTurnRow({ label, onOpen }) {
194
193
  const line = 'mb-0.5 flex h-7 w-full items-center gap-2 text-left text-xs text-text-muted';
@@ -1,20 +1,21 @@
1
+ import { type ReactNode } from 'react';
1
2
  import type { ChatCheckpointDiff, ChatToolItem, ChatTurnItem } from '@adecore/agent-contracts';
2
3
  export declare function ToggleLine({ icon, label, detail, open, onToggle, failed, live, trailing, className }: {
3
- icon: React.ReactNode;
4
+ icon: ReactNode;
4
5
  label: string;
5
6
  detail?: string;
6
7
  open: boolean;
7
8
  onToggle(): void;
8
9
  failed?: boolean;
9
10
  live?: boolean;
10
- trailing?: React.ReactNode;
11
+ trailing?: ReactNode;
11
12
  className?: string;
12
13
  }): import("react").JSX.Element;
13
14
  export declare function WorkRow({ tool, nested, detail, icon, label, failed }: {
14
15
  tool: ChatToolItem;
15
16
  nested?: boolean;
16
17
  detail?: string;
17
- icon?: React.ReactNode;
18
+ icon?: ReactNode;
18
19
  label?: string;
19
20
  failed?: boolean;
20
21
  }): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Suspense, useEffect, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import i18next from 'i18next';
@@ -30,15 +30,24 @@ export function ToggleLine({ icon, label, detail, open, onToggle, failed, live,
30
30
  // Fix the width so long commands reach the truncation inside the negative margins.
31
31
  '-mx-1 mb-0.5 flex h-7 w-[calc(100%+8px)] items-center gap-2 rounded-md px-1 text-left text-xs text-text-muted hover:bg-surface-hover', failed && 'text-status-error', className), onClick: onToggle, children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: icon }), _jsx("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: label }), detail && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: detail })), _jsx("span", { className: "grow" }), trailing, failed && _jsx(Icon, { icon: X, size: 12, className: "shrink-0" }), _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') })] }));
32
32
  }
33
- function ToolBody({ tool }) {
33
+ /* The diff renderers load lazily, so a diff waits on them under a line that says so. */
34
+ function DiffSuspense({ children }) {
34
35
  const { t } = useTranslation('agent-chat');
36
+ return _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: children });
37
+ }
38
+ /* A file in a changed files card, which opens its diff under it. */
39
+ function ChangedFileLine({ path, open, onToggle, trailing, children }) {
40
+ return (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": path, onClick: onToggle, children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: path }), _jsx("span", { className: "grow" }), trailing] }), open && _jsx("div", { className: "border-t border-border", children: children })] }));
41
+ }
42
+ function ToolBody({ tool }) {
35
43
  const patches = unifiedChanges(tool);
36
44
  const changes = patches.length > 0 ? [] : fileChanges(tool.name, tool.input);
37
- return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
45
+ return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(DiffSuspense, { children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(DiffSuspense, { children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
38
46
  }
39
- /* One settled tool call: a line, and its input and output behind it. An image the call looked at
40
- is drawn under the line, because a picture says more about that read than its path does.
41
- `icon`, `label` and `failed` let a row that knows the call better name it in its own words. */
47
+ /*
48
+ * One settled tool call: a line, and its input and output behind it. An image the call looked at is
49
+ * drawn under the line. `icon`, `label` and `failed` let a row that knows the call better name it.
50
+ */
42
51
  export function WorkRow({ tool, nested, detail, icon, label, failed }) {
43
52
  const [open, setOpen] = useState(false);
44
53
  const image = tool.state === 'done' ? readImagePath(tool.name, tool.input) : null;
@@ -76,17 +85,16 @@ function omittedLabel(reason) {
76
85
  }
77
86
  /* One file of a turn's checkpoint diff: its patch, or the reason there is none. */
78
87
  function CheckpointFileBody({ file }) {
79
- const { t } = useTranslation('agent-chat');
80
88
  if (file.omitted) {
81
89
  return _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: omittedLabel(file.omitted) });
82
90
  }
83
- return (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(UnifiedDiff, { change: file }) }));
91
+ return (_jsx(DiffSuspense, { children: _jsx(UnifiedDiff, { change: file }) }));
84
92
  }
85
93
  /* The frame of a changed files card: a header that folds the list of files away. */
86
94
  function ChangedFilesCard({ count, truncated, added, deleted, revealNonce = 0, children }) {
87
95
  const { t } = useTranslation('agent-chat');
88
96
  const [expanded, setExpanded] = useState(false);
89
- // 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.
97
+ // A jump opens the card, also one that mounted it by scrolling it on screen; the flash is gone by the next visit.
90
98
  const [seenReveal, setSeenReveal] = useState(0);
91
99
  if (revealNonce > 0 && revealNonce !== seenReveal) {
92
100
  setSeenReveal(revealNonce);
@@ -127,7 +135,7 @@ export function ChangedFilesRow({ tools, diff, checkpoint, chatId, turnId }) {
127
135
  const revealNonce = flash !== null && flash.opens && flash.key === keyOf(chatId) && flash.rowId === `files-${turnId}` ? flash.nonce : 0;
128
136
  if (checkpointDiff !== null && checkpointDiff.files.length > 0) {
129
137
  const files = checkpointDiff.files;
130
- return (_jsx(ChangedFilesCard, { revealNonce: revealNonce, count: files.length, truncated: checkpointDiff.truncated, added: files.reduce((sum, file) => sum + file.added, 0), deleted: files.reduce((sum, file) => sum + file.deleted, 0), children: files.map((file) => (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": file.path, onClick: () => setOpen((o) => ({ ...o, [file.path]: !o[file.path] })), children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open[file.path] && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: file.path }), _jsx("span", { className: "grow" }), _jsxs("span", { className: "text-chat-added tabular-nums", children: ["+", file.added] }), _jsxs("span", { className: "text-chat-removed tabular-nums", children: ["-", file.deleted] })] }), open[file.path] && (_jsx("div", { className: "border-t border-border", children: _jsx(CheckpointFileBody, { file: file }) }))] }, file.path))) }));
138
+ return (_jsx(ChangedFilesCard, { revealNonce: revealNonce, count: files.length, truncated: checkpointDiff.truncated, added: files.reduce((sum, file) => sum + file.added, 0), deleted: files.reduce((sum, file) => sum + file.deleted, 0), children: files.map((file) => (_jsx(ChangedFileLine, { path: file.path, open: open[file.path] === true, onToggle: () => setOpen((o) => ({ ...o, [file.path]: !o[file.path] })), trailing: _jsxs(_Fragment, { children: [_jsxs("span", { className: "text-chat-added tabular-nums", children: ["+", file.added] }), _jsxs("span", { className: "text-chat-removed tabular-nums", children: ["-", file.deleted] })] }), children: _jsx(CheckpointFileBody, { file: file }) }, file.path))) }));
131
139
  }
132
140
  return _jsx(ProviderChangedFiles, { tools: tools, open: open, setOpen: setOpen, revealNonce: revealNonce });
133
141
  }
@@ -156,10 +164,7 @@ function ProviderChangedFiles({ tools, open, setOpen, revealNonce }) {
156
164
  entryFor(change.path || tool.id).edits.push(change);
157
165
  }
158
166
  }
159
- return (_jsx(ChangedFilesCard, { count: byPath.size, revealNonce: revealNonce, children: [...byPath].map(([path, entry]) => {
160
- const count = entry.edits.length + entry.patches.length;
161
- return (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": path, onClick: () => setOpen((o) => ({ ...o, [path]: !o[path] })), children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open[path] && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: path }), _jsx("span", { className: "grow" }), _jsx("span", { className: "text-text-faint", children: t('work.edits', { count }) })] }), open[path] && (_jsx("div", { className: "border-t border-border", children: _jsxs(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: [entry.patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, `patch-${index}`))), entry.edits.map((change, index) => (_jsx(EditDiff, { change: change }, `edit-${index}`)))] }) }))] }, path));
162
- }) }));
167
+ return (_jsx(ChangedFilesCard, { count: byPath.size, revealNonce: revealNonce, children: [...byPath].map(([path, entry]) => (_jsx(ChangedFileLine, { path: path, open: open[path] === true, onToggle: () => setOpen((o) => ({ ...o, [path]: !o[path] })), trailing: _jsx("span", { className: "text-text-faint", children: t('work.edits', { count: entry.edits.length + entry.patches.length }) }), children: _jsxs(DiffSuspense, { children: [entry.patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, `patch-${index}`))), entry.edits.map((change, index) => (_jsx(EditDiff, { change: change }, `edit-${index}`)))] }) }, path))) }));
163
168
  }
164
169
  /* "Working for 00:14". */
165
170
  export function WorkingRow({ startedAt }) {
@@ -1,6 +1,5 @@
1
1
  import type { VisualScrollRequest } from '@adecore/agent-contracts/visual';
2
2
  import type { VisualBridge } from './visual-bridge.js';
3
- /** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
4
3
  export declare class VisualViewportController {
5
4
  private readonly box;
6
5
  private readonly viewport;
@@ -18,5 +17,6 @@ export declare class VisualViewportController {
18
17
  refresh(): void;
19
18
  scroll(request: VisualScrollRequest): void;
20
19
  dispose(): void;
20
+ private offsetInScroller;
21
21
  private schedule;
22
22
  }
@@ -1,5 +1,5 @@
1
1
  import { visualViewportGeometry } from './logic/visual-viewport.js';
2
- /** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
2
+ /* Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
3
3
  export class VisualViewportController {
4
4
  box;
5
5
  viewport;
@@ -35,11 +35,7 @@ export class VisualViewportController {
35
35
  if (this.disposed) {
36
36
  return;
37
37
  }
38
- const outer = this.scroller.getBoundingClientRect();
39
- const box = this.box.getBoundingClientRect();
40
- // A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
41
- const scale = outer.height / this.scroller.offsetHeight || 1;
42
- const top = (box.top - outer.top) / scale - this.scroller.clientTop;
38
+ const top = this.offsetInScroller(this.scroller.getBoundingClientRect(), this.box.getBoundingClientRect());
43
39
  const available = this.scroller.clientHeight;
44
40
  const geometry = visualViewportGeometry(this.box.offsetHeight, available, top);
45
41
  this.viewport.style.height = `${geometry.height}px`;
@@ -61,8 +57,7 @@ export class VisualViewportController {
61
57
  return;
62
58
  }
63
59
  this.pendingDelta = 0;
64
- const scale = outer.height / this.scroller.offsetHeight || 1;
65
- const start = this.scroller.scrollTop + (box.top - outer.top) / scale - this.scroller.clientTop;
60
+ const start = this.scroller.scrollTop + this.offsetInScroller(outer, box);
66
61
  const top = 'to' in request ? start + request.to : request.edge === 'start' ? 0 : this.scroller.scrollHeight;
67
62
  this.scroller.scrollTo({ top, behavior: 'instant' });
68
63
  this.refresh();
@@ -75,6 +70,12 @@ export class VisualViewportController {
75
70
  this.scroller.removeEventListener('scroll', this.onScroll);
76
71
  this.bridge.setViewport(null);
77
72
  }
73
+ /* The box's top below the scroller's visible top, in the scroller's own pixels. */
74
+ offsetInScroller(outer, box) {
75
+ // A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
76
+ const scale = outer.height / this.scroller.offsetHeight || 1;
77
+ return (box.top - outer.top) / scale - this.scroller.clientTop;
78
+ }
78
79
  schedule() {
79
80
  if (this.frame || this.disposed) {
80
81
  return;
@@ -93,15 +93,11 @@ export function portTransport(port) {
93
93
  return promise;
94
94
  },
95
95
  on(event, handler) {
96
- let handlers = eventHandlers.get(event);
97
- if (!handlers) {
98
- handlers = new Set();
99
- eventHandlers.set(event, handlers);
100
- }
101
- const set = handlers;
102
- set.add(handler);
96
+ const handlers = eventHandlers.get(event) ?? new Set();
97
+ eventHandlers.set(event, handlers);
98
+ handlers.add(handler);
103
99
  return () => {
104
- set.delete(handler);
100
+ handlers.delete(handler);
105
101
  };
106
102
  },
107
103
  subscribeStatus(handler) {
@@ -113,6 +113,14 @@ export function applyEvent(state, event) {
113
113
  return stateOf(event.info, event.items);
114
114
  }
115
115
  }
116
+ /* Fields of a held chat that are not part of its thread; a chat this client does not hold stays as it is. */
117
+ function withFields(store, key, fields) {
118
+ const current = store.byKey[key];
119
+ if (!current) {
120
+ return {};
121
+ }
122
+ return { byKey: { ...store.byKey, [key]: { ...current, ...fields } } };
123
+ }
116
124
  export const useChats = create((set) => ({
117
125
  byKey: {},
118
126
  statusByKey: {},
@@ -166,22 +174,10 @@ export const useChats = create((set) => ({
166
174
  });
167
175
  },
168
176
  bookmarks(key, bookmarks) {
169
- set((s) => {
170
- const current = s.byKey[key];
171
- if (!current) {
172
- return {};
173
- }
174
- return { byKey: { ...s.byKey, [key]: { ...current, bookmarks } } };
175
- });
177
+ set((s) => withFields(s, key, { bookmarks }));
176
178
  },
177
179
  visuals(key, visuals) {
178
- set((s) => {
179
- const current = s.byKey[key];
180
- if (!current) {
181
- return {};
182
- }
183
- return { byKey: { ...s.byKey, [key]: { ...current, visuals } } };
184
- });
180
+ set((s) => withFields(s, key, { visuals }));
185
181
  },
186
182
  forget(key) {
187
183
  set((s) => {
@@ -32,12 +32,6 @@ function parsePreferences(raw) {
32
32
  }
33
33
  }
34
34
  const storage = persistedJson(storageKey, parsePreferences, DEFAULTS, () => chatStorageLegacyKeys('usage'));
35
- function readPreferences() {
36
- return storage.read();
37
- }
38
- function persist(preferences) {
39
- storage.write(preferences);
40
- }
41
35
  /* The three stored choices out of the store, so a setter writes the other two back unchanged. */
42
36
  function chosen(state) {
43
37
  return { period: state.period, metric: state.metric, currency: state.currency };
@@ -49,10 +43,7 @@ function pad(value) {
49
43
  export function dayOf(date) {
50
44
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
51
45
  }
52
- /*
53
- * What a period asks for. Today counts from local midnight in hours, the way the commit log already
54
- * groups days, so every other period reads as one bar per calendar day.
55
- */
46
+ /* What a period asks for: today in hours from local midnight, every other period a bar per calendar day. */
56
47
  export function windowFor(period, now = new Date()) {
57
48
  const days = USAGE_PERIODS.find((entry) => entry.id === period)?.days ?? 7;
58
49
  const from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1));
@@ -75,46 +66,50 @@ export function askedKey(payload) {
75
66
  const EMPTY = { asked: null, summary: null, loading: false, failed: false, limits: null };
76
67
  /* Reads the period, metric and currency again, once another window of the same origin wrote them. */
77
68
  export function reloadUsagePreferences() {
78
- useUsageStore.setState(readPreferences());
69
+ useUsageStore.setState(storage.read());
79
70
  }
80
- export const useUsageStore = createChatStore((set, get) => ({
81
- ...DEFAULTS,
82
- byScope: {},
83
- chosen: null,
84
- choose(scopeId) {
85
- set({ chosen: scopeId });
86
- },
87
- setPeriod(period) {
88
- persist({ ...chosen(get()), period });
89
- set({ period });
90
- },
91
- setMetric(metric) {
92
- persist({ ...chosen(get()), metric });
93
- set({ metric });
94
- },
95
- setCurrency(currency) {
96
- persist({ ...chosen(get()), currency });
97
- set({ currency });
98
- },
99
- setLoading(scopeId, loading) {
100
- set({ byScope: { ...get().byScope, [scopeId]: { ...(get().byScope[scopeId] ?? EMPTY), loading } } });
101
- },
102
- receive(scopeId, asked, summary) {
103
- set({
104
- byScope: { ...get().byScope, [scopeId]: { ...(get().byScope[scopeId] ?? EMPTY), asked, summary, loading: false, failed: false } }
105
- });
106
- },
107
- fail(scopeId) {
108
- set({ byScope: { ...get().byScope, [scopeId]: { ...(get().byScope[scopeId] ?? EMPTY), loading: false, failed: true } } });
109
- },
110
- setLimits(scopeId, limits) {
111
- set({ byScope: { ...get().byScope, [scopeId]: { ...(get().byScope[scopeId] ?? EMPTY), limits } } });
112
- },
113
- forget(scopeId) {
114
- const { [scopeId]: _gone, ...rest } = get().byScope;
115
- set({ byScope: rest });
116
- }
117
- }), (store) => store.setState(readPreferences()));
71
+ export const useUsageStore = createChatStore((set, get) => {
72
+ const choosePreference = (change) => {
73
+ storage.write({ ...chosen(get()), ...change });
74
+ set(change);
75
+ };
76
+ const patchRow = (scopeId, change) => {
77
+ set({ byScope: { ...get().byScope, [scopeId]: { ...(get().byScope[scopeId] ?? EMPTY), ...change } } });
78
+ };
79
+ return {
80
+ ...DEFAULTS,
81
+ byScope: {},
82
+ chosen: null,
83
+ choose(scopeId) {
84
+ set({ chosen: scopeId });
85
+ },
86
+ setPeriod(period) {
87
+ choosePreference({ period });
88
+ },
89
+ setMetric(metric) {
90
+ choosePreference({ metric });
91
+ },
92
+ setCurrency(currency) {
93
+ choosePreference({ currency });
94
+ },
95
+ setLoading(scopeId, loading) {
96
+ patchRow(scopeId, { loading });
97
+ },
98
+ receive(scopeId, asked, summary) {
99
+ patchRow(scopeId, { asked, summary, loading: false, failed: false });
100
+ },
101
+ fail(scopeId) {
102
+ patchRow(scopeId, { loading: false, failed: true });
103
+ },
104
+ setLimits(scopeId, limits) {
105
+ patchRow(scopeId, { limits });
106
+ },
107
+ forget(scopeId) {
108
+ const { [scopeId]: _gone, ...rest } = get().byScope;
109
+ set({ byScope: rest });
110
+ }
111
+ };
112
+ }, (store) => store.setState(storage.read()));
118
113
  export function useUsage(select) {
119
114
  const { id } = useChatScope();
120
115
  return useUsageStore((s) => select({ period: s.period, metric: s.metric, currency: s.currency, ...(s.byScope[id] ?? EMPTY) }));
package/dist/storage.js CHANGED
@@ -64,25 +64,25 @@ export function createChatStore(initial, hydrate) {
64
64
  hydrate(store);
65
65
  };
66
66
  hydrators.push(hydrateOnce);
67
- const useStore = ((...args) => {
67
+ const ready = () => {
68
68
  hydrateChatStorage();
69
69
  hydrateOnce();
70
+ };
71
+ const useStore = ((...args) => {
72
+ ready();
70
73
  return store(...args);
71
74
  });
72
75
  Object.assign(useStore, store);
73
76
  useStore.getState = () => {
74
- hydrateChatStorage();
75
- hydrateOnce();
77
+ ready();
76
78
  return store.getState();
77
79
  };
78
80
  useStore.subscribe = (...args) => {
79
- hydrateChatStorage();
80
- hydrateOnce();
81
+ ready();
81
82
  return store.subscribe(...args);
82
83
  };
83
84
  useStore.setState = ((...args) => {
84
- hydrateChatStorage();
85
- hydrateOnce();
85
+ ready();
86
86
  store.setState(...args);
87
87
  });
88
88
  return useStore;
package/dist/theme.css CHANGED
@@ -76,8 +76,6 @@
76
76
  }
77
77
 
78
78
  @layer components {
79
- /* ---------------------------------------------------------------- Chat */
80
-
81
79
  /* Text in a chat is there to be read and copied, so the thread selects even though the app does
82
80
  not. Its chrome stays out of it: a drag on a fold header or a chip would select the row.
83
81
  The pretty wrap from `body` would be recomputed on the growing paragraph every frame a reply
@@ -160,18 +158,12 @@
160
158
  }
161
159
 
162
160
  /*
163
- * Markdown, outside every layer on purpose. The typography plugin registers `.prose` as a utility,
164
- * and a later layer beats an earlier one whatever the specificity, so a rule that has to outrank
165
- * prose cannot live in `components`. Unlayered styles win over every layer, which is the one
166
- * place the app's tokens and scale are sure to land on top of the plugin's.
167
- */
168
- /*
169
- * Markdown anywhere in the app: the chat timeline, a note node and the file preview all render
170
- * the same `Markdown` component, which carries `prose prose-sm max-w-none text-sm` next to this
171
- * class. Prose brings the rhythm; everything here is what the app decides instead of the plugin.
172
- * The semantic tokens flip with the theme on their own, so the prose variables read from them
173
- * and `prose-invert` is a second source of truth the app does not need. The size comes from the
174
- * `text-sm` utility, which a node, a view and the file preview each move.
161
+ * Markdown, outside every layer on purpose: the typography plugin registers `.prose` as a utility,
162
+ * and only unlayered styles are sure to land on top of it.
163
+ *
164
+ * Every `Markdown` carries `prose prose-sm max-w-none text-sm` next to this class. Prose brings the
165
+ * rhythm; everything here is what the app decides instead. The semantic tokens flip with the theme on
166
+ * their own, so the prose variables read from them and `prose-invert` is not needed.
175
167
  */
176
168
  .prose.chat-markdown {
177
169
  --tw-prose-body: var(--text);
@@ -9,8 +9,7 @@ import { formatClock, formatWeekdayClock, isSameDay, formatCountdown, formatPerc
9
9
  import { ProviderLogo } from '../agents/ProviderLogo.js';
10
10
  import { PROVIDER_COLORS, PROVIDER_LABELS } from './format.js';
11
11
  import { accountNote, explain, isSignedOut, nextReset } from './limit-groups.js';
12
- /* Red where a window is nearly spent, amber where it is worth knowing. A window with room to spare
13
- is not news, so it takes the text color rather than a hue that competes with the two that are. */
12
+ /* Red where a window is nearly spent, amber where it is worth knowing; room to spare is not news and takes the text color. */
14
13
  function toneOf(used) {
15
14
  return used >= 0.9 ? 'bg-status-error' : used >= 0.7 ? 'bg-status-needs-you' : 'bg-text';
16
15
  }
@@ -12,11 +12,9 @@ import { modelNameFromSlug } from '../agents/model-name.js';
12
12
  import { formatCount, formatTokens, PROVIDER_COLORS, PROVIDER_LABELS, shortPath, slotLabel } from './format.js';
13
13
  import { useMoney } from './money.js';
14
14
  import { deriveDays } from './summary.js';
15
- /* The row of every table, the same height and the same hover as a commit row in the git panel. */
16
15
  const ROW = 'flex h-8 items-center gap-2 rounded-md px-2 text-xs hover:bg-surface-hover';
17
16
  const HEAD = 'flex h-6 items-center gap-2 px-2 text-xs text-text-faint';
18
- /* A row is padded so its hover has room around the text, and the table pulls that padding back off
19
- again. The first column then starts on the same line as every other section of the page. */
17
+ /* The table pulls the padding that gives a row's hover room back off, so its first column lines up with the page. */
20
18
  const TABLE = '-mx-2 flex flex-col';
21
19
  const TABS = ['models', 'projects', 'day'];
22
20
  /* A share of the biggest row, never under one pixel. A row that did something has to be visible. */
@@ -2,10 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import i18next from 'i18next';
3
3
  import { Dialog } from '@adecore/ui';
4
4
  /*
5
- * The frame of the usage page: larger than the settings, since the chart and the breakdown need the
6
- * width. What goes in it (`UsagePage`, in an `ErrorBoundary` of the app's) mounts only while it is
7
- * open, so nothing is scanned behind a closed dialog. It is a dialog and not a view, since none of it
8
- * belongs to one project.
5
+ * The frame of the usage page, wider than the settings for the chart and the breakdown. What goes in
6
+ * it mounts only while it is open, so nothing is scanned behind a closed dialog.
9
7
  */
10
8
  export function UsageDialog({ open, onOpenChange, children }) {
11
9
  return (_jsx(Dialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(Dialog.Popup, { className: "flex h-[820px] w-[1080px] flex-col", children: [children, _jsx(Dialog.Description, { className: "sr-only", children: i18next.t('agent-usage:dialog.description') })] }) }));
@@ -1,7 +1,6 @@
1
1
  import { formatDay, formatHour, formatWeekdayDay, formatMoney, formatNumber, formatTokens } from '@adecore/ui/format';
2
2
  export { formatClock } from '@adecore/ui/format';
3
- /* Every number on this page is read in the region of the machine looking at it, as a bank statement
4
- would be: `$5,480.96` in the US, `$ 5.480,96` in the Netherlands. The labels stay English. */
3
+ /* Read in the region of the machine looking at it: `$5,480.96` in the US, `$ 5.480,96` in the Netherlands. */
5
4
  export function formatCount(value) {
6
5
  return formatNumber(value);
7
6
  }
@@ -23,8 +22,10 @@ export function moneyFormat(currency, rate) {
23
22
  }
24
23
  export const PROVIDER_LABELS = { claude: 'Claude Code', codex: 'Codex' };
25
24
  export const PROVIDER_COLORS = { claude: 'var(--chart-claude)', codex: 'var(--chart-codex)' };
26
- /* A slot is `2026-09-10` or `2026-09-10T14`, already in the viewer's own zone, so it is read as
27
- plain wall clock and never handed to a parser that would shift it back. */
25
+ /*
26
+ * A slot is `2026-09-10` or `2026-09-10T14`, already in the viewer's own zone, so it is read as plain
27
+ * wall clock and never handed to a parser that would shift it back.
28
+ */
28
29
  function dateOfSlot(slot) {
29
30
  return new Date(Number(slot.slice(0, 4)), Number(slot.slice(5, 7)) - 1, Number(slot.slice(8, 10)));
30
31
  }
@@ -47,8 +48,7 @@ export function slotAxisLabel(slot) {
47
48
  export function formatDate(at) {
48
49
  return formatDay(at);
49
50
  }
50
- /* A path under a row that already carries the name: enough of the tail to tell two checkouts of the
51
- same repository apart, without the home directory nobody needs to read again. */
51
+ /* Enough of a path's tail to tell two checkouts of the same repository apart, under a row that already carries the name. */
52
52
  export function shortPath(path) {
53
53
  const parts = path.split('/').filter(Boolean);
54
54
  return parts.length <= 3 ? path : `…/${parts.slice(-3).join('/')}`;
@@ -1,11 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useUsage } from '../state/usage.js';
3
3
  import { moneyFormat } from './format.js';
4
- /*
5
- * The formatter every amount on the page goes through: the currency that was chosen, at the rate
6
- * the summary on screen carries. It is a hook rather than a prop because every row wants it and
7
- * none of them wants to know where the rate came from.
8
- */
4
+ /* The formatter every amount on the page goes through: the chosen currency, at the rate the summary on screen carries. */
9
5
  export function useMoney() {
10
6
  const currency = useUsage((s) => s.currency);
11
7
  const rate = useUsage((s) => s.summary?.rate ?? null);
@@ -74,10 +74,8 @@ export function deriveUsage(summary, metric) {
74
74
  };
75
75
  }
76
76
  /*
77
- * The same buckets the chart draws, written out as a table of calendar days, newest first. Today is
78
- * drawn per hour but read per day, so an hour slot is folded onto the date it names. A day nothing
79
- * happened in is left out, since the chart already shows the gap, and a quiet fortnight would otherwise
80
- * be fourteen rows of zeroes between the days worth reading.
77
+ * The chart's buckets as a table of calendar days, newest first; an hour slot folds onto the date it
78
+ * names. A day nothing happened in is left out, since the chart already shows the gap.
81
79
  */
82
80
  export function deriveDays(summary) {
83
81
  const rows = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1011,8 +1011,8 @@
1011
1011
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
1012
1012
  },
1013
1013
  "dependencies": {
1014
- "@adecore/agent-contracts": "0.21.1",
1015
- "@adecore/intelligent-ui": "0.21.1",
1014
+ "@adecore/agent-contracts": "0.21.2",
1015
+ "@adecore/intelligent-ui": "0.21.2",
1016
1016
  "@codemirror/commands": "^6.11.1",
1017
1017
  "@codemirror/language": "^6.12.4",
1018
1018
  "@codemirror/state": "^6.7.6",
@@ -1033,7 +1033,7 @@
1033
1033
  "zustand": "^5.0.15"
1034
1034
  },
1035
1035
  "peerDependencies": {
1036
- "@adecore/ui": "^0.21.1",
1036
+ "@adecore/ui": "^0.21.2",
1037
1037
  "i18next": "^26.4.2",
1038
1038
  "react": "^19.3.0",
1039
1039
  "react-dom": "^19.3.0",