@adecore/agents-react 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/chat/ui/EditDiff.d.ts +3 -1
  2. package/dist/chat/ui/EditDiff.js +25 -18
  3. package/dist/chat/ui/UnifiedDiff.d.ts +2 -11
  4. package/dist/chat/ui/UnifiedDiff.js +31 -58
  5. package/dist/chat/ui/diff/ApprovalReview.d.ts +5 -0
  6. package/dist/chat/ui/diff/ApprovalReview.js +29 -0
  7. package/dist/chat/ui/diff/DiffFallbackBoundary.d.ts +17 -0
  8. package/dist/chat/ui/diff/DiffFallbackBoundary.js +17 -0
  9. package/dist/chat/ui/diff/DiffOmitted.d.ts +3 -0
  10. package/dist/chat/ui/diff/DiffOmitted.js +8 -0
  11. package/dist/chat/ui/diff/DiffStack.d.ts +9 -0
  12. package/dist/chat/ui/diff/DiffStack.js +15 -0
  13. package/dist/chat/ui/diff/DiffUnavailable.d.ts +3 -0
  14. package/dist/chat/ui/diff/DiffUnavailable.js +8 -0
  15. package/dist/chat/ui/diff/EditorEditDiff.d.ts +23 -0
  16. package/dist/chat/ui/diff/EditorEditDiff.js +67 -0
  17. package/dist/chat/ui/diff/EditorPatchDiff.d.ts +16 -0
  18. package/dist/chat/ui/diff/EditorPatchDiff.js +20 -0
  19. package/dist/chat/ui/diff/LegacyDiffPool.d.ts +4 -0
  20. package/dist/chat/ui/diff/LegacyDiffPool.js +12 -0
  21. package/dist/chat/ui/diff/LegacyEditDiff.d.ts +5 -0
  22. package/dist/chat/ui/diff/LegacyEditDiff.js +23 -0
  23. package/dist/chat/ui/diff/LegacyUnifiedDiff.d.ts +2 -0
  24. package/dist/chat/ui/diff/LegacyUnifiedDiff.js +67 -0
  25. package/dist/chat/ui/diff/RawDiff.d.ts +6 -0
  26. package/dist/chat/ui/diff/RawDiff.js +7 -0
  27. package/dist/chat/ui/diff/ReviewDiff.d.ts +5 -0
  28. package/dist/chat/ui/diff/ReviewDiff.js +10 -0
  29. package/dist/chat/ui/diff/diff-props.d.ts +12 -0
  30. package/dist/chat/ui/diff/diff-props.js +1 -0
  31. package/dist/chat/ui/diff/diff-source-budget.d.ts +10 -0
  32. package/dist/chat/ui/diff/diff-source-budget.js +47 -0
  33. package/dist/chat/ui/diff/editor-edit-model.d.ts +8 -0
  34. package/dist/chat/ui/diff/editor-edit-model.js +19 -0
  35. package/dist/chat/ui/diff/review-plan.d.ts +28 -0
  36. package/dist/chat/ui/diff/review-plan.js +48 -0
  37. package/dist/chat/ui/diff/review-settle.d.ts +5 -0
  38. package/dist/chat/ui/diff/review-settle.js +12 -0
  39. package/dist/chat/ui/diff/syntax-runtime.d.ts +18 -0
  40. package/dist/chat/ui/diff/syntax-runtime.js +184 -0
  41. package/dist/chat/ui/diff/use-review-stack.d.ts +8 -0
  42. package/dist/chat/ui/diff/use-review-stack.js +30 -0
  43. package/dist/chat/ui/diff-syntax.d.ts +19 -0
  44. package/dist/chat/ui/diff-syntax.js +63 -0
  45. package/dist/chat/ui/rows/WorkRows.js +16 -5
  46. package/dist/chat/ui/use-diff-source-tokens.d.ts +4 -0
  47. package/dist/chat/ui/use-diff-source-tokens.js +40 -0
  48. package/dist/host.d.ts +6 -1
  49. package/dist/lazy.d.ts +3 -1
  50. package/dist/lazy.js +5 -3
  51. package/dist/locales/en/agent-chat.json +3 -0
  52. package/dist/locales/en/agent-prompts.json +1 -0
  53. package/dist/locales/nl/agent-chat.json +3 -0
  54. package/dist/locales/nl/agent-prompts.json +1 -0
  55. package/dist/prompts/ui/ApprovalBody.js +7 -5
  56. package/package.json +18 -5
  57. package/src/chat/ui/EditDiff.tsx +37 -22
  58. package/src/chat/ui/UnifiedDiff.tsx +55 -88
  59. package/src/chat/ui/diff/ApprovalReview.tsx +53 -0
  60. package/src/chat/ui/diff/DiffFallbackBoundary.tsx +32 -0
  61. package/src/chat/ui/diff/DiffOmitted.tsx +13 -0
  62. package/src/chat/ui/diff/DiffStack.tsx +45 -0
  63. package/src/chat/ui/diff/DiffUnavailable.tsx +13 -0
  64. package/src/chat/ui/diff/EditorEditDiff.tsx +145 -0
  65. package/src/chat/ui/diff/EditorPatchDiff.tsx +60 -0
  66. package/src/chat/ui/diff/LegacyDiffPool.tsx +13 -0
  67. package/src/chat/ui/diff/LegacyEditDiff.tsx +33 -0
  68. package/src/chat/ui/diff/LegacyUnifiedDiff.tsx +93 -0
  69. package/src/chat/ui/diff/RawDiff.tsx +19 -0
  70. package/src/chat/ui/diff/ReviewDiff.tsx +22 -0
  71. package/src/chat/ui/diff/diff-props.ts +16 -0
  72. package/src/chat/ui/diff/diff-source-budget.ts +56 -0
  73. package/src/chat/ui/diff/editor-edit-model.ts +21 -0
  74. package/src/chat/ui/diff/review-plan.ts +69 -0
  75. package/src/chat/ui/diff/review-settle.ts +14 -0
  76. package/src/chat/ui/diff/syntax-runtime.ts +222 -0
  77. package/src/chat/ui/diff/use-review-stack.ts +38 -0
  78. package/src/chat/ui/diff-syntax.ts +82 -0
  79. package/src/chat/ui/rows/WorkRows.tsx +42 -33
  80. package/src/chat/ui/use-diff-source-tokens.ts +43 -0
  81. package/src/host.ts +10 -1
  82. package/src/lazy.tsx +6 -3
  83. package/src/locales/en/agent-chat.json +3 -0
  84. package/src/locales/en/agent-prompts.json +1 -0
  85. package/src/locales/nl/agent-chat.json +3 -0
  86. package/src/locales/nl/agent-prompts.json +1 -0
  87. package/src/prompts/ui/ApprovalBody.tsx +8 -14
@@ -157,7 +157,10 @@
157
157
  "work": {
158
158
  "moreCharacters_one": "[{{count}} more character]",
159
159
  "moreCharacters_other": "[{{count}} more characters]",
160
+ "diffLabel": "Changes in {{path}}",
161
+ "showDiff": "Show diff",
160
162
  "loadingDiff": "Loading diff",
163
+ "diffUnavailable": "Could not load this diff",
161
164
  "runningFor": "running for {{elapsed}}",
162
165
  "forksAfter_one": "A fork goes on after this turn",
163
166
  "forksAfter_other": "{{count}} forks go on after this turn",
@@ -18,6 +18,7 @@
18
18
  "allChanges_one": "Allow applies to the {{count}} change in this request.",
19
19
  "allChanges_other": "Allow applies to all {{count}} changes in this request.",
20
20
  "loadingDiff": "Loading diff…",
21
+ "reviewChanges": "Changes to review",
21
22
  "viewDiff": "View full diff",
22
23
  "collapseDiff": "Collapse diff",
23
24
  "details": "Details",
@@ -157,7 +157,10 @@
157
157
  "work": {
158
158
  "moreCharacters_one": "[nog {{count}} teken]",
159
159
  "moreCharacters_other": "[nog {{count}} tekens]",
160
+ "diffLabel": "Wijzigingen in {{path}}",
161
+ "showDiff": "Diff tonen",
160
162
  "loadingDiff": "Diff laden",
163
+ "diffUnavailable": "Deze diff kon niet worden geladen",
161
164
  "runningFor": "loopt al {{elapsed}}",
162
165
  "forksAfter_one": "Een fork gaat verder na deze turn",
163
166
  "forksAfter_other": "{{count}} forks gaan verder na deze turn",
@@ -18,6 +18,7 @@
18
18
  "allChanges_one": "Toestaan geldt voor de {{count}} wijziging in dit verzoek.",
19
19
  "allChanges_other": "Toestaan geldt voor alle {{count}} wijzigingen in dit verzoek.",
20
20
  "loadingDiff": "Diff laden…",
21
+ "reviewChanges": "Wijzigingen beoordelen",
21
22
  "viewDiff": "Volledige diff tonen",
22
23
  "collapseDiff": "Diff inklappen",
23
24
  "details": "Details",
@@ -1,14 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Suspense, useState } from 'react';
2
+ import { useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { approvalChanges, fileChanges } from '../../chat/logic/tools.js';
6
6
  import { chatHost } from '../../host.js';
7
- import { lazyNamed } from '../../lazy.js';
7
+ import { ApprovalReview } from '../../chat/ui/diff/ApprovalReview.js';
8
+ import { reviewEntries } from '../../chat/ui/diff/review-plan.js';
8
9
  import { isPrimaryKey } from '../logic/keys.js';
9
10
  import { Button, TextMenu } from '@adecore/ui';
10
- const EditDiff = lazyNamed(() => import('../../chat/ui/EditDiff.js'), 'default');
11
- const UnifiedDiff = lazyNamed(() => import('../../chat/ui/UnifiedDiff.js'), 'default');
12
11
  /* A command in the sunken box a person reads before allowing it, with the folder it runs in when that is known. */
13
12
  export function CommandBox({ command, cwd }) {
14
13
  return (_jsxs(TextMenu, { className: "rounded-xl bg-surface-sunken p-3", children: [cwd !== undefined && _jsx("p", { className: "mb-2 break-all font-mono text-xs text-text-muted select-text", children: cwd }), _jsx("pre", { className: "whitespace-pre-wrap break-words font-mono text-code text-text select-text", children: command })] }));
@@ -18,8 +17,11 @@ function ApprovalDetails({ item }) {
18
17
  const [expanded, setExpanded] = useState(false);
19
18
  const patches = approvalChanges(item.input);
20
19
  const edits = patches.length ? [] : fileChanges(item.toolName, item.input);
20
+ const entries = reviewEntries(patches, edits);
21
+ const editorPreview = chatHost().code.editor !== undefined;
22
+ const maxHeight = expanded ? 480 : 176;
21
23
  const input = typeof item.input === 'object' && item.input !== null ? item.input : {};
22
- return (_jsxs(_Fragment, { children: [item.description && _jsx("p", { className: "text-sm text-text-muted", children: item.description }), patches.length + edits.length > 1 && (_jsx("p", { className: "text-xs text-text-muted", children: t('approval.allChanges', { count: patches.length + edits.length }) })), patches.length + edits.length > 0 ? (_jsxs("div", { className: "overflow-hidden rounded-xl bg-surface-sunken", children: [_jsx("div", { className: clsx('overflow-auto', !expanded && 'max-h-44'), children: _jsxs(Suspense, { fallback: _jsx("p", { className: "p-3 text-xs text-text-muted", children: t('approval.loadingDiff') }), children: [patches.map((change, i) => (_jsx(UnifiedDiff, { change: change }, i))), edits.map((change, i) => (_jsx(EditDiff, { change: change }, i)))] }) }), _jsx("div", { className: "flex items-center border-t border-border p-1", children: _jsx(Button, { size: "sm", "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: expanded ? t('approval.collapseDiff') : t('approval.viewDiff') }) })] })) : typeof input.command === 'string' ? (_jsx(CommandBox, { command: input.command, cwd: typeof input.cwd === 'string' ? input.cwd : undefined })) : (_jsxs("details", { children: [_jsx("summary", { className: "flex h-7 cursor-pointer items-center text-xs text-text-muted", children: t('approval.details') }), _jsx(TextMenu, { children: _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-surface-sunken p-3 font-mono text-code text-text select-text", children: JSON.stringify(item.input, null, 2) }) })] }))] }));
24
+ return (_jsxs(_Fragment, { children: [item.description && _jsx("p", { className: "text-sm text-text-muted", children: item.description }), patches.length + edits.length > 1 && (_jsx("p", { className: "text-xs text-text-muted", children: t('approval.allChanges', { count: patches.length + edits.length }) })), patches.length + edits.length > 0 ? (_jsxs("div", { className: "overflow-hidden rounded-xl bg-surface-sunken", children: [_jsx("div", { className: clsx(editorPreview ? 'overflow-hidden' : 'overflow-auto', !editorPreview && !expanded && 'max-h-44'), children: _jsx(ApprovalReview, { entries: entries, maxHeight: maxHeight }, item.requestId) }), _jsx("div", { className: "flex items-center border-t border-border p-1", children: _jsx(Button, { size: "sm", "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: expanded ? t('approval.collapseDiff') : t('approval.viewDiff') }) })] })) : typeof input.command === 'string' ? (_jsx(CommandBox, { command: input.command, cwd: typeof input.cwd === 'string' ? input.cwd : undefined })) : (_jsxs("details", { children: [_jsx("summary", { className: "flex h-7 cursor-pointer items-center text-xs text-text-muted", children: t('approval.details') }), _jsx(TextMenu, { children: _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-surface-sunken p-3 font-mono text-code text-text select-text", children: JSON.stringify(item.input, null, 2) }) })] }))] }));
23
25
  }
24
26
  /* A chat's permission request: the diff, the command or the raw input, and the reason a Deny may carry. */
25
27
  export function ApprovalBody({ item, draft, onDraft, denyReason, locked }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -514,6 +514,11 @@
514
514
  "types": "./dist/chat/ui/composer/menu-aria.d.ts",
515
515
  "default": "./dist/chat/ui/composer/menu-aria.js"
516
516
  },
517
+ "./chat/ui/diff-syntax": {
518
+ "source": "./src/chat/ui/diff-syntax.ts",
519
+ "types": "./dist/chat/ui/diff-syntax.d.ts",
520
+ "default": "./dist/chat/ui/diff-syntax.js"
521
+ },
517
522
  "./chat/ui/diff-theme": {
518
523
  "source": "./src/chat/ui/diff-theme.ts",
519
524
  "types": "./dist/chat/ui/diff-theme.d.ts",
@@ -1027,6 +1032,11 @@
1027
1032
  "source": "./src/chat/visual-viewport.ts",
1028
1033
  "types": "./dist/chat/visual-viewport.d.ts",
1029
1034
  "default": "./dist/chat/visual-viewport.js"
1035
+ },
1036
+ "./chat/ui/use-diff-source-tokens": {
1037
+ "source": "./src/chat/ui/use-diff-source-tokens.ts",
1038
+ "types": "./dist/chat/ui/use-diff-source-tokens.d.ts",
1039
+ "default": "./dist/chat/ui/use-diff-source-tokens.js"
1030
1040
  }
1031
1041
  },
1032
1042
  "scripts": {
@@ -1036,8 +1046,10 @@
1036
1046
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
1037
1047
  },
1038
1048
  "dependencies": {
1039
- "@adecore/agent-contracts": "0.22.1",
1040
- "@adecore/intelligent-ui": "0.22.1",
1049
+ "@adecore/agent-contracts": "0.23.0",
1050
+ "@adecore/intelligent-ui": "0.23.0",
1051
+ "@adecore/editor": "0.23.0",
1052
+ "@adecore/merge": "0.23.0",
1041
1053
  "@codemirror/commands": "^6.11.1",
1042
1054
  "@codemirror/language": "^6.12.4",
1043
1055
  "@codemirror/state": "^6.7.6",
@@ -1055,10 +1067,11 @@
1055
1067
  "shiki": "^4.4.3",
1056
1068
  "simple-icons": "^16.32.0",
1057
1069
  "zod": "^4.6.5",
1058
- "zustand": "^5.0.15"
1070
+ "zustand": "^5.0.15",
1071
+ "@adecore/editor-react": "0.23.0"
1059
1072
  },
1060
1073
  "peerDependencies": {
1061
- "@adecore/ui": "^0.22.1",
1074
+ "@adecore/ui": "^0.23.0",
1062
1075
  "i18next": "^26.4.2",
1063
1076
  "react": "^19.3.0",
1064
1077
  "react-dom": "^19.3.0",
@@ -1,30 +1,45 @@
1
- import { useMemo } from 'react';
2
- import { MultiFileDiff } from '@pierre/diffs/react';
1
+ import { Suspense, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { lazyNamed } from '../../lazy';
4
+ import { diffSourceBudget, legacyDiffBudget } from './diff/diff-source-budget';
5
+ import { DiffOmitted } from './diff/DiffOmitted';
6
+ import { DiffUnavailable } from './diff/DiffUnavailable';
7
+ import { DiffFallbackBoundary } from './diff/DiffFallbackBoundary';
3
8
  import { chatHost } from '../../host';
4
- import { useDiffTheme } from './diff-theme';
5
9
 
6
10
  import type { FileChange } from '../logic/tools';
7
11
 
8
- /* One hunk of a file edit, highlighted in a worker. The header is ours, so the library's is off. */
9
- export default function EditDiff({ change }: { change: FileChange }) {
10
- const resolved = chatHost().code.useMode();
11
- const theme = useDiffTheme();
12
- const oldFile = useMemo(() => ({ name: change.path, contents: change.before }), [change.path, change.before]);
13
- const newFile = useMemo(() => ({ name: change.path, contents: change.after }), [change.path, change.after]);
14
- const options = useMemo(
15
- () => ({
16
- diffStyle: 'unified' as const,
17
- theme,
18
- themeType: resolved,
19
- disableFileHeader: true,
20
- overflow: 'wrap' as const,
21
- hunkSeparators: 'simple' as const
22
- }),
23
- [theme, resolved]
12
+ const LegacyEditDiff = lazyNamed(() => import('./diff/LegacyEditDiff'), 'default', { prefetch: false });
13
+ const EditorEditDiff = lazyNamed(() => import('./diff/EditorEditDiff'), 'default');
14
+
15
+ export default function EditDiff({ change, allowEditor = true, maxHeight }: { change: FileChange; allowEditor?: boolean; maxHeight?: number }) {
16
+ const { t } = useTranslation('agent-chat');
17
+ const loadEngine = chatHost().code.editor;
18
+ const sourceAllowed = useMemo(() => diffSourceBudget(change.before, change.after), [change.before, change.after]);
19
+ const legacyMode = !allowEditor || loadEngine === undefined;
20
+ const legacyAllowed = useMemo(() => legacyMode && legacyDiffBudget(change.before, change.after), [legacyMode, change.before, change.after]);
21
+ const omitted = <DiffOmitted path={change.path} />;
22
+ const fallback = legacyAllowed ? (
23
+ <DiffFallbackBoundary fallback={omitted} resetKeys={[change.path, change.before, change.after]}>
24
+ <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
25
+ <LegacyEditDiff change={change} maxHeight={maxHeight} />
26
+ </Suspense>
27
+ </DiffFallbackBoundary>
28
+ ) : (
29
+ omitted
24
30
  );
31
+ if (!sourceAllowed) {
32
+ return omitted;
33
+ }
34
+ if (legacyMode) {
35
+ return fallback;
36
+ }
37
+ const unavailable = <DiffUnavailable path={change.path} />;
25
38
  return (
26
- <div className="chat-diff select-text">
27
- <MultiFileDiff oldFile={oldFile} newFile={newFile} options={options} />
28
- </div>
39
+ <DiffFallbackBoundary fallback={unavailable} resetKeys={[change.path, change.before, change.after, loadEngine]}>
40
+ <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
41
+ <EditorEditDiff change={change} loadEngine={loadEngine!} fallback={unavailable} maxHeight={maxHeight} sourceChecked />
42
+ </Suspense>
43
+ </DiffFallbackBoundary>
29
44
  );
30
45
  }
@@ -1,97 +1,64 @@
1
- import { useMemo } from 'react';
2
- import clsx from 'clsx';
3
- import { FileDiff, PatchDiff } from '@pierre/diffs/react';
4
- import type { ChatFileChange } from '@adecore/agent-contracts';
1
+ import { Suspense, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { lazyNamed } from '../../lazy';
4
+ import { diffSourceBudget, legacyDiffBudget, patchSourceBudget } from './diff/diff-source-budget';
5
+ import { DiffOmitted } from './diff/DiffOmitted';
6
+ import { DiffUnavailable } from './diff/DiffUnavailable';
7
+ import { RawDiff } from './diff/RawDiff';
8
+ import { DiffFallbackBoundary } from './diff/DiffFallbackBoundary';
5
9
  import { chatHost } from '../../host';
6
- import { useDiffTheme } from './diff-theme';
7
- import { fullFileDiff, type DiffContents } from './full-diff';
10
+ import type { UnifiedDiffProps } from './diff/diff-props';
8
11
 
9
- /*
10
- * A patch the CLI reported itself. The renderer needs a file header, which a CLI that only sends hunks
11
- * gets from the path we know; a diff without hunks is shown as its own lines, so nothing is swallowed.
12
- */
13
- function asPatch(change: ChatFileChange): string | null {
14
- if (!/^@@/m.test(change.diff)) {
15
- return null;
16
- }
17
- if (/^\+\+\+ /m.test(change.diff)) {
18
- return change.diff;
19
- }
20
- const from = change.kind === 'add' ? '/dev/null' : `a/${change.path}`;
21
- const to = change.kind === 'delete' ? '/dev/null' : `b/${change.path}`;
22
- return `--- ${from}\n+++ ${to}\n${change.diff}`;
23
- }
12
+ const LegacyUnifiedDiff = lazyNamed(() => import('./diff/LegacyUnifiedDiff'), 'default', { prefetch: false });
13
+ const EditorPatchDiff = lazyNamed(() => import('./diff/EditorPatchDiff'), 'default');
24
14
 
25
- /* Lets a diff with a view of its own run to its bottom, so a short one's scrollbar sits there and not under its last line. */
26
- const FILL_CSS = `
27
- :host { display: flex; flex-direction: column; flex-grow: 1; }
28
- pre { flex-grow: 1; align-content: start; }
29
- pre[data-diff-type="single"] { display: flex; flex-direction: column; }
30
- pre[data-diff-type="split"][data-overflow="scroll"]:not([data-dehydrated]) { align-content: stretch; }
31
- pre > [data-code] { flex-grow: 1; align-self: stretch; align-content: start; }
32
- `;
33
-
34
- function lineClass(line: string): string {
35
- if (line.startsWith('+')) {
36
- return 'text-chat-added';
15
+ export default function UnifiedDiff(props: UnifiedDiffProps) {
16
+ const { t } = useTranslation('agent-chat');
17
+ const { change, contents, allowEditor = true, ignoreWhitespace = false, overflow = 'wrap', diffStyle = 'unified', fill = false, maxHeight } = props;
18
+ const loadEngine = chatHost().code.editor;
19
+ const oldText = contents?.old ?? '';
20
+ const newText = contents?.new ?? '';
21
+ const sourceAllowed = useMemo(() => diffSourceBudget(oldText, newText) && patchSourceBudget(change.diff), [oldText, newText, change.diff]);
22
+ const legacyMode = !allowEditor || loadEngine === undefined;
23
+ const hasHunks = /^@@/m.test(change.diff);
24
+ const legacyAllowed = useMemo(() => legacyMode && legacyDiffBudget(oldText, newText, change.diff), [legacyMode, oldText, newText, change.diff]);
25
+ const rawAllowed = useMemo(() => hasHunks || legacyDiffBudget('', '', change.diff), [hasHunks, change.diff]);
26
+ const omitted = <DiffOmitted path={change.path} />;
27
+ const fallback = legacyAllowed ? (
28
+ <DiffFallbackBoundary fallback={omitted} resetKeys={[change.path, change.diff, oldText, newText, diffStyle]}>
29
+ <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
30
+ <LegacyUnifiedDiff {...props} />
31
+ </Suspense>
32
+ </DiffFallbackBoundary>
33
+ ) : (
34
+ omitted
35
+ );
36
+ if (!sourceAllowed) {
37
+ return omitted;
37
38
  }
38
- if (line.startsWith('-')) {
39
- return 'text-chat-removed';
39
+ if (!hasHunks) {
40
+ return rawAllowed ? <RawDiff text={change.diff} overflow={overflow} fill={fill} maxHeight={maxHeight} /> : omitted;
40
41
  }
41
- return 'text-text-muted';
42
- }
43
-
44
- interface UnifiedDiffProps {
45
- change: ChatFileChange;
46
- overflow?: 'wrap' | 'scroll';
47
- /* `split` puts the old and the new side by side; the chat always stacks. */
48
- diffStyle?: 'unified' | 'split';
49
- /* Grow to the height of a flex column around it. */
50
- fill?: boolean;
51
- /* Both whole texts the patch was made between, with which a person can unfold the lines between hunks. */
52
- contents?: DiffContents;
53
- }
54
-
55
- export default function UnifiedDiff({ change, overflow = 'wrap', diffStyle = 'unified', fill = false, contents }: UnifiedDiffProps) {
56
- const resolved = chatHost().code.useMode();
57
- const theme = useDiffTheme();
58
- const patch = useMemo(() => asPatch(change), [change]);
59
- const oldText = contents?.old;
60
- const newText = contents?.new;
61
- const full = useMemo(
62
- () => (patch === null || oldText === undefined || newText === undefined ? null : fullFileDiff(patch, change.path, { old: oldText, new: newText })),
63
- [patch, change.path, oldText, newText]
64
- );
65
- const unfolds = full !== null;
66
- const options = useMemo(
67
- () => ({
68
- theme,
69
- themeType: resolved,
70
- disableFileHeader: true,
71
- diffStyle,
72
- overflow,
73
- hunkSeparators: unfolds ? ('line-info' as const) : ('simple' as const),
74
- unsafeCSS: fill ? FILL_CSS : undefined
75
- }),
76
- [diffStyle, overflow, theme, resolved, fill, unfolds]
77
- );
78
- if (patch === null) {
79
- return (
80
- <pre className="max-h-64 overflow-auto px-3 py-2 font-mono text-code select-text">
81
- {change.diff
82
- .replace(/\n$/, '')
83
- .split('\n')
84
- .map((line, index) => (
85
- <div key={index} className={clsx(lineClass(line), 'whitespace-pre-wrap')}>
86
- {line}
87
- </div>
88
- ))}
89
- </pre>
90
- );
42
+ if (legacyMode) {
43
+ return fallback;
91
44
  }
45
+ const unavailable = <DiffUnavailable path={change.path} />;
92
46
  return (
93
- <div className={clsx('chat-diff select-text', fill && 'flex grow flex-col')}>
94
- {full === null ? <PatchDiff patch={patch} options={options} /> : <FileDiff fileDiff={full} options={options} />}
95
- </div>
47
+ <DiffFallbackBoundary fallback={unavailable} resetKeys={[change.path, change.diff, oldText, newText, ignoreWhitespace, diffStyle, loadEngine]}>
48
+ <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
49
+ <EditorPatchDiff
50
+ change={change}
51
+ sourceChecked
52
+ contents={contents}
53
+ ignoreWhitespace={ignoreWhitespace}
54
+ wrap={overflow === 'wrap'}
55
+ fill={fill}
56
+ maxHeight={maxHeight}
57
+ layout={diffStyle}
58
+ loadEngine={loadEngine!}
59
+ fallback={unavailable}
60
+ />
61
+ </Suspense>
62
+ </DiffFallbackBoundary>
96
63
  );
97
64
  }
@@ -0,0 +1,53 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button } from '@adecore/ui';
4
+ import { formatNumber } from '@adecore/ui/format';
5
+ import { ReviewDiff } from './ReviewDiff';
6
+ import type { ReviewEntry } from './review-plan';
7
+
8
+ export function ApprovalReview({ entries, maxHeight }: { entries: readonly ReviewEntry[]; maxHeight: number }) {
9
+ const { t } = useTranslation('agent-prompts');
10
+ const [selected, setSelected] = useState<string | null>(null);
11
+ const active = entries.find((entry) => entry.key === selected) ?? entries[0];
12
+ const labels = useMemo(() => {
13
+ const totals = new Map<string, number>();
14
+ const counts = new Map<string, number>();
15
+ for (const entry of entries) {
16
+ totals.set(entry.path, (totals.get(entry.path) ?? 0) + 1);
17
+ }
18
+ return entries.map((entry) => {
19
+ const count = (counts.get(entry.path) ?? 0) + 1;
20
+ counts.set(entry.path, count);
21
+ const total = totals.get(entry.path)!;
22
+ const suffix = total > 1 ? ` (${formatNumber(count)}/${formatNumber(total)})` : '';
23
+ return { short: `${entry.path.split(/[\\/]/).at(-1)}${suffix}`, full: `${entry.path}${suffix}` };
24
+ });
25
+ }, [entries]);
26
+ if (active === undefined) {
27
+ return null;
28
+ }
29
+ return (
30
+ <div>
31
+ {entries.length > 1 && (
32
+ <>
33
+ <div role="toolbar" aria-label={t('approval.reviewChanges')} className="flex flex-wrap gap-1 border-b border-border p-1">
34
+ {entries.map((entry, index) => (
35
+ <Button
36
+ key={entry.key}
37
+ size="sm"
38
+ variant={active.key === entry.key ? 'secondary' : 'ghost'}
39
+ aria-label={labels[index]!.full}
40
+ aria-pressed={active.key === entry.key}
41
+ onClick={() => setSelected(entry.key)}
42
+ >
43
+ {labels[index]!.short}
44
+ </Button>
45
+ ))}
46
+ </div>
47
+ <p className="break-all px-3 py-2 font-mono text-xs text-text-muted">{active.path}</p>
48
+ </>
49
+ )}
50
+ <ReviewDiff key={active.key} entry={active} maxHeight={maxHeight} />
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,32 @@
1
+ import { Component, type ReactNode } from 'react';
2
+
3
+ interface Props {
4
+ fallback: ReactNode;
5
+ children: ReactNode;
6
+ resetKeys: readonly unknown[];
7
+ }
8
+
9
+ interface State {
10
+ failed: boolean;
11
+ resetKeys: readonly unknown[];
12
+ }
13
+
14
+ /* A failed view or lazy chunk must leave the containing review card usable. */
15
+ export class DiffFallbackBoundary extends Component<Props, State> {
16
+ state: State = { failed: false, resetKeys: this.props.resetKeys };
17
+
18
+ static getDerivedStateFromError(): Partial<State> {
19
+ return { failed: true };
20
+ }
21
+
22
+ static getDerivedStateFromProps(props: Props, state: State): State | null {
23
+ if (state.resetKeys.length !== props.resetKeys.length || state.resetKeys.some((value, index) => !Object.is(value, props.resetKeys[index]))) {
24
+ return { failed: false, resetKeys: props.resetKeys };
25
+ }
26
+ return null;
27
+ }
28
+
29
+ render(): ReactNode {
30
+ return this.state.failed ? this.props.fallback : this.props.children;
31
+ }
32
+ }
@@ -0,0 +1,13 @@
1
+ import { useReviewSettled } from './review-settle';
2
+ import { useTranslation } from 'react-i18next';
3
+
4
+ export function DiffOmitted({ path }: { path: string }) {
5
+ useReviewSettled();
6
+ const { t } = useTranslation('agent-chat');
7
+ return (
8
+ <div role="status" data-diff-omitted className="px-3 py-2 text-xs text-text-muted">
9
+ <p className="truncate font-mono">{path}</p>
10
+ <p>{t('work.omitted.tooLarge')}</p>
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,45 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { Button } from '@adecore/ui';
3
+ import { ReviewSettleContext } from './review-settle';
4
+ import { ReviewDiff } from './ReviewDiff';
5
+ import { reviewSize, type ReviewEntry } from './review-plan';
6
+ import { useReviewStack, type ReviewStack } from './use-review-stack';
7
+
8
+ export function ReviewStackView({ entries, stack }: { entries: readonly ReviewEntry[]; stack: ReviewStack }) {
9
+ const { t } = useTranslation('agent-chat');
10
+ return (
11
+ <div data-review-stack>
12
+ {entries.map((entry) => (
13
+ <div key={entry.key} className="border-t border-border first:border-t-0">
14
+ <div className="flex items-center gap-2 px-3 py-2">
15
+ <p className="min-w-0 grow break-all font-mono text-xs text-text-muted">{entry.path}</p>
16
+ {stack.slots.get(entry.key) === 'deferred' && (
17
+ <Button size="sm" data-review-show={entry.key} onClick={() => stack.promote(entry.key)}>
18
+ {t('work.showDiff')}
19
+ </Button>
20
+ )}
21
+ </div>
22
+ {stack.ready.has(entry.key) ? (
23
+ <ReviewSettleContext value={{ key: entry.key, settle: stack.settle }}>
24
+ <ReviewDiff entry={entry} />
25
+ </ReviewSettleContext>
26
+ ) : stack.slots.get(entry.key) === 'editor' ? (
27
+ <div
28
+ data-review-pending={entry.key}
29
+ role="status"
30
+ className="px-3 py-2 text-xs text-text-faint"
31
+ style={{ height: Math.max(28, Math.min(reviewSize(entry).rows * 20, 480)) }}
32
+ >
33
+ {t('work.loadingDiff')}
34
+ </div>
35
+ ) : null}
36
+ </div>
37
+ ))}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function DiffStack({ entries }: { entries: readonly ReviewEntry[] }) {
43
+ const stack = useReviewStack(entries);
44
+ return <ReviewStackView entries={entries} stack={stack} />;
45
+ }
@@ -0,0 +1,13 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { useReviewSettled } from './review-settle';
3
+
4
+ export function DiffUnavailable({ path }: { path: string }) {
5
+ useReviewSettled();
6
+ const { t } = useTranslation('agent-chat');
7
+ return (
8
+ <div role="status" data-diff-unavailable className="px-3 py-2 text-xs text-text-muted">
9
+ <p className="truncate font-mono">{path}</p>
10
+ <p>{t('work.diffUnavailable')}</p>
11
+ </div>
12
+ );
13
+ }