@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
@@ -0,0 +1,145 @@
1
+ import { useReviewSettled } from './review-settle';
2
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { getFiletypeFromFileName } from '@pierre/diffs';
5
+ import type { EditorEngine } from '@adecore/editor';
6
+ import { DiffView } from '@adecore/editor-react';
7
+ import { diffDocument, patchOf, type DiffDocument } from '@adecore/merge';
8
+ import { chatHost } from '../../../host';
9
+ import type { FileChange } from '../../logic/tools';
10
+ import { useDiffSourceTokens } from '../use-diff-source-tokens';
11
+ import { diffSourceBudget } from './diff-source-budget';
12
+ import { DiffOmitted } from './DiffOmitted';
13
+ import { canRenderEditorDiff, EDITOR_DIFF_LIMITS, EDITOR_DIFF_WORK } from './editor-edit-model';
14
+
15
+ type EngineLoader = () => Promise<EditorEngine>;
16
+
17
+ // Virtualized rows remount; retain the engine so their height does not return to the loading placeholder.
18
+ const engines = new WeakMap<EngineLoader, EditorEngine | null>();
19
+
20
+ function ReadyDiff({
21
+ document,
22
+ path,
23
+ engine,
24
+ wrap,
25
+ fill,
26
+ layout,
27
+ maxHeight
28
+ }: {
29
+ document: DiffDocument;
30
+ path: string;
31
+ engine: EditorEngine;
32
+ wrap: boolean;
33
+ fill: boolean;
34
+ layout: 'unified' | 'split';
35
+ maxHeight: number;
36
+ }) {
37
+ useReviewSettled();
38
+ const { t } = useTranslation('agent-chat');
39
+ const mode = chatHost().code.useMode();
40
+ const theme = chatHost().code.useThemes()[mode];
41
+ const language = getFiletypeFromFileName(path);
42
+ const tokens = useDiffSourceTokens(document, language, theme);
43
+ return (
44
+ <DiffView
45
+ engine={engine}
46
+ document={document}
47
+ tokens={tokens}
48
+ theme={theme}
49
+ maxHeight={maxHeight}
50
+ unfoldLimits={EDITOR_DIFF_LIMITS}
51
+ label={t('work.diffLabel', { path })}
52
+ layout={layout}
53
+ wrap={wrap}
54
+ fill={fill}
55
+ className="chat-diff select-text"
56
+ />
57
+ );
58
+ }
59
+
60
+ export function EditorDocumentDiff({
61
+ document,
62
+ path,
63
+ loadEngine,
64
+ fallback,
65
+ wrap = true,
66
+ fill = false,
67
+ layout = 'unified',
68
+ maxHeight = 480
69
+ }: {
70
+ document: DiffDocument;
71
+ path: string;
72
+ loadEngine: EngineLoader;
73
+ fallback: ReactNode;
74
+ wrap?: boolean;
75
+ fill?: boolean;
76
+ layout?: 'unified' | 'split';
77
+ maxHeight?: number;
78
+ }) {
79
+ const { t } = useTranslation('agent-chat');
80
+ const withinLimits = useMemo(() => canRenderEditorDiff(document), [document]);
81
+ const [loaded, setLoaded] = useState<{ load: EngineLoader; engine: EditorEngine | null }>();
82
+ useEffect(() => {
83
+ if (!withinLimits || engines.has(loadEngine)) {
84
+ return;
85
+ }
86
+ let active = true;
87
+ void Promise.resolve()
88
+ .then(loadEngine)
89
+ .then(
90
+ (engine) => {
91
+ engines.set(loadEngine, engine);
92
+ if (active) {
93
+ setLoaded({ load: loadEngine, engine });
94
+ }
95
+ },
96
+ () => {
97
+ engines.set(loadEngine, null);
98
+ if (active) {
99
+ setLoaded({ load: loadEngine, engine: null });
100
+ }
101
+ }
102
+ );
103
+ return () => {
104
+ active = false;
105
+ };
106
+ }, [loadEngine, withinLimits]);
107
+ const engine = loaded?.load === loadEngine ? loaded.engine : engines.get(loadEngine);
108
+ if (!withinLimits) {
109
+ return <DiffOmitted path={path} />;
110
+ }
111
+ if (engine === undefined) {
112
+ return <div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>;
113
+ }
114
+ return engine === null ? (
115
+ fallback
116
+ ) : (
117
+ <ReadyDiff document={document} path={path} engine={engine} wrap={wrap} fill={fill} layout={layout} maxHeight={maxHeight} />
118
+ );
119
+ }
120
+
121
+ export default function EditorEditDiff({
122
+ change,
123
+ loadEngine,
124
+ fallback,
125
+ maxHeight,
126
+ sourceChecked = false
127
+ }: {
128
+ change: FileChange;
129
+ sourceChecked?: boolean;
130
+ loadEngine: EngineLoader;
131
+ fallback: ReactNode;
132
+ maxHeight?: number;
133
+ }) {
134
+ const document = useMemo(
135
+ () =>
136
+ sourceChecked || diffSourceBudget(change.before, change.after)
137
+ ? diffDocument(patchOf(change.before, change.after, 3, { maxWork: EDITOR_DIFF_WORK }), { old: change.before, new: change.after })
138
+ : undefined,
139
+ [change.before, change.after, sourceChecked]
140
+ );
141
+ if (document === undefined) {
142
+ return <DiffOmitted path={change.path} />;
143
+ }
144
+ return <EditorDocumentDiff document={document} path={change.path} loadEngine={loadEngine} fallback={fallback} maxHeight={maxHeight} />;
145
+ }
@@ -0,0 +1,60 @@
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import type { ChatFileChange } from '@adecore/agent-contracts';
3
+ import type { EditorEngine } from '@adecore/editor';
4
+ import { diffDocument, parsePatch } from '@adecore/merge';
5
+ import type { DiffContents } from '../full-diff';
6
+ import { diffSourceBudget, patchSourceBudget } from './diff-source-budget';
7
+ import { DiffOmitted } from './DiffOmitted';
8
+ import { EditorDocumentDiff } from './EditorEditDiff';
9
+
10
+ export default function EditorPatchDiff({
11
+ change,
12
+ sourceChecked = false,
13
+ contents,
14
+ ignoreWhitespace,
15
+ wrap,
16
+ fill,
17
+ maxHeight,
18
+ layout = 'unified',
19
+ loadEngine,
20
+ fallback
21
+ }: {
22
+ change: ChatFileChange;
23
+ sourceChecked?: boolean;
24
+ contents?: DiffContents;
25
+ ignoreWhitespace: boolean;
26
+ wrap: boolean;
27
+ fill: boolean;
28
+ layout?: 'unified' | 'split';
29
+ maxHeight?: number;
30
+ loadEngine: () => Promise<EditorEngine>;
31
+ fallback: ReactNode;
32
+ }) {
33
+ const oldText = contents?.old;
34
+ const newText = contents?.new;
35
+ const document = useMemo(() => {
36
+ if (!sourceChecked && (!diffSourceBudget(oldText ?? '', newText ?? '') || !patchSourceBudget(change.diff))) {
37
+ return undefined;
38
+ }
39
+ return diffDocument(
40
+ parsePatch(change.diff),
41
+ oldText === undefined || newText === undefined ? undefined : { old: oldText, new: newText },
42
+ ignoreWhitespace
43
+ );
44
+ }, [change.diff, oldText, newText, ignoreWhitespace, sourceChecked]);
45
+ if (document === undefined) {
46
+ return <DiffOmitted path={change.path} />;
47
+ }
48
+ return (
49
+ <EditorDocumentDiff
50
+ document={document}
51
+ path={change.path}
52
+ loadEngine={loadEngine}
53
+ fallback={fallback}
54
+ wrap={wrap}
55
+ fill={fill}
56
+ layout={layout}
57
+ maxHeight={maxHeight}
58
+ />
59
+ );
60
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useWorkerPool } from '@pierre/diffs/react';
3
+ import { chatHost } from '../../../host';
4
+ import DiffPool from '../DiffPool';
5
+
6
+ export function LegacyDiffPool({ children }: { children: ReactNode }) {
7
+ const outer = useWorkerPool();
8
+ const workerFactory = chatHost().code.legacyDiffWorker;
9
+ if (outer !== undefined || workerFactory === undefined) {
10
+ return children;
11
+ }
12
+ return <DiffPool workerFactory={workerFactory}>{children}</DiffPool>;
13
+ }
@@ -0,0 +1,33 @@
1
+ import { LegacyDiffPool } from './LegacyDiffPool';
2
+ import { useReviewSettled } from './review-settle';
3
+ import { useMemo } from 'react';
4
+ import { MultiFileDiff } from '@pierre/diffs/react';
5
+ import { chatHost } from '../../../host';
6
+ import { useDiffTheme } from '../diff-theme';
7
+ import type { FileChange } from '../../logic/tools';
8
+
9
+ export default function LegacyEditDiff({ change, maxHeight }: { change: FileChange; maxHeight?: number }) {
10
+ useReviewSettled();
11
+ const resolved = chatHost().code.useMode();
12
+ const theme = useDiffTheme();
13
+ const oldFile = useMemo(() => ({ name: change.path, contents: change.before }), [change.path, change.before]);
14
+ const newFile = useMemo(() => ({ name: change.path, contents: change.after }), [change.path, change.after]);
15
+ const options = useMemo(
16
+ () => ({
17
+ diffStyle: 'unified' as const,
18
+ theme,
19
+ themeType: resolved,
20
+ disableFileHeader: true,
21
+ overflow: 'wrap' as const,
22
+ hunkSeparators: 'simple' as const
23
+ }),
24
+ [theme, resolved]
25
+ );
26
+ return (
27
+ <div className="chat-diff select-text" style={maxHeight === undefined ? undefined : { maxHeight, overflow: 'auto' }}>
28
+ <LegacyDiffPool>
29
+ <MultiFileDiff oldFile={oldFile} newFile={newFile} options={options} />
30
+ </LegacyDiffPool>
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,93 @@
1
+ import { LegacyDiffPool } from './LegacyDiffPool';
2
+ import { useReviewSettled } from './review-settle';
3
+ import { useMemo } from 'react';
4
+ import clsx from 'clsx';
5
+ import { FileDiff, PatchDiff } from '@pierre/diffs/react';
6
+ import type { ChatFileChange } from '@adecore/agent-contracts';
7
+ import { chatHost } from '../../../host';
8
+ import { useDiffTheme } from '../diff-theme';
9
+ import { fullFileDiff } from '../full-diff';
10
+ import type { UnifiedDiffProps } from './diff-props';
11
+
12
+ /*
13
+ * A patch the CLI reported itself. The renderer needs a file header, which a CLI that only sends hunks
14
+ * gets from the path we know; a diff without hunks is shown as its own lines, so nothing is swallowed.
15
+ */
16
+ function asPatch(change: ChatFileChange): string | null {
17
+ if (!/^@@/m.test(change.diff)) {
18
+ return null;
19
+ }
20
+ if (/^\+\+\+ /m.test(change.diff)) {
21
+ return change.diff;
22
+ }
23
+ const from = change.kind === 'add' ? '/dev/null' : `a/${change.path}`;
24
+ const to = change.kind === 'delete' ? '/dev/null' : `b/${change.path}`;
25
+ return `--- ${from}\n+++ ${to}\n${change.diff}`;
26
+ }
27
+
28
+ /* 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. */
29
+ const FILL_CSS = `
30
+ :host { display: flex; flex-direction: column; flex-grow: 1; }
31
+ pre { flex-grow: 1; align-content: start; }
32
+ pre[data-diff-type="single"] { display: flex; flex-direction: column; }
33
+ pre[data-diff-type="split"][data-overflow="scroll"]:not([data-dehydrated]) { align-content: stretch; }
34
+ pre > [data-code] { flex-grow: 1; align-self: stretch; align-content: start; }
35
+ `;
36
+
37
+ function lineClass(line: string): string {
38
+ if (line.startsWith('+')) {
39
+ return 'text-chat-added';
40
+ }
41
+ if (line.startsWith('-')) {
42
+ return 'text-chat-removed';
43
+ }
44
+ return 'text-text-muted';
45
+ }
46
+
47
+ export default function LegacyUnifiedDiff({ change, overflow = 'wrap', diffStyle = 'unified', fill = false, contents, maxHeight }: UnifiedDiffProps) {
48
+ useReviewSettled();
49
+ const resolved = chatHost().code.useMode();
50
+ const theme = useDiffTheme();
51
+ const patch = useMemo(() => asPatch(change), [change]);
52
+ const oldText = contents?.old;
53
+ const newText = contents?.new;
54
+ const full = useMemo(
55
+ () => (patch === null || oldText === undefined || newText === undefined ? null : fullFileDiff(patch, change.path, { old: oldText, new: newText })),
56
+ [patch, change.path, oldText, newText]
57
+ );
58
+ const unfolds = full !== null;
59
+ const options = useMemo(
60
+ () => ({
61
+ theme,
62
+ themeType: resolved,
63
+ disableFileHeader: true,
64
+ diffStyle,
65
+ overflow,
66
+ hunkSeparators: unfolds ? ('line-info' as const) : ('simple' as const),
67
+ unsafeCSS: fill ? FILL_CSS : undefined
68
+ }),
69
+ [diffStyle, overflow, theme, resolved, fill, unfolds]
70
+ );
71
+ if (patch === null) {
72
+ return (
73
+ <pre className="max-h-64 overflow-auto px-3 py-2 font-mono text-code select-text" style={fill ? undefined : { maxHeight }}>
74
+ {change.diff
75
+ .replace(/\n$/, '')
76
+ .split('\n')
77
+ .map((line, index) => (
78
+ <div key={index} className={clsx(lineClass(line), 'whitespace-pre-wrap')}>
79
+ {line}
80
+ </div>
81
+ ))}
82
+ </pre>
83
+ );
84
+ }
85
+ return (
86
+ <div
87
+ className={clsx('chat-diff select-text', fill && 'flex grow flex-col')}
88
+ style={fill || maxHeight === undefined ? undefined : { maxHeight, overflow: 'auto' }}
89
+ >
90
+ <LegacyDiffPool>{full === null ? <PatchDiff patch={patch} options={options} /> : <FileDiff fileDiff={full} options={options} />}</LegacyDiffPool>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,19 @@
1
+ import clsx from 'clsx';
2
+ import { useReviewSettled } from './review-settle';
3
+
4
+ export function RawDiff({ text, overflow, fill, maxHeight }: { text: string; overflow: 'wrap' | 'scroll'; fill: boolean; maxHeight?: number }) {
5
+ useReviewSettled();
6
+ return (
7
+ <pre
8
+ data-diff-raw
9
+ className={clsx(
10
+ 'overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text',
11
+ fill && 'h-full min-h-0',
12
+ overflow === 'wrap' ? 'whitespace-pre-wrap' : 'whitespace-pre'
13
+ )}
14
+ style={fill ? undefined : { maxHeight: maxHeight ?? 256 }}
15
+ >
16
+ {text.replace(/\n$/, '')}
17
+ </pre>
18
+ );
19
+ }
@@ -0,0 +1,22 @@
1
+ import { Suspense } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { lazyNamed } from '../../../lazy';
4
+ import type { ReviewEntry } from './review-plan';
5
+
6
+ const EditDiff = lazyNamed(() => import('../EditDiff'), 'default');
7
+ const UnifiedDiff = lazyNamed(() => import('../UnifiedDiff'), 'default');
8
+
9
+ export function ReviewDiff({ entry, maxHeight = 480 }: { entry: ReviewEntry; maxHeight?: number }) {
10
+ const { t } = useTranslation('agent-chat');
11
+ return (
12
+ <div data-review-entry={entry.key}>
13
+ <Suspense fallback={<p className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</p>}>
14
+ {entry.change.kind === 'edit' ? (
15
+ <EditDiff change={entry.change.edit} maxHeight={maxHeight} />
16
+ ) : (
17
+ <UnifiedDiff change={entry.change.patch} contents={entry.change.contents} maxHeight={maxHeight} />
18
+ )}
19
+ </Suspense>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,16 @@
1
+ import type { ChatFileChange } from '@adecore/agent-contracts';
2
+ import type { DiffContents } from '../full-diff';
3
+
4
+ export interface UnifiedDiffProps {
5
+ allowEditor?: boolean;
6
+ maxHeight?: number;
7
+ ignoreWhitespace?: boolean;
8
+ change: ChatFileChange;
9
+ overflow?: 'wrap' | 'scroll';
10
+ /* `split` puts the old and the new side by side; the chat always stacks. */
11
+ diffStyle?: 'unified' | 'split';
12
+ /* Grow to the height of a flex column around it. */
13
+ fill?: boolean;
14
+ /* Both whole texts the patch was made between, with which a person can unfold the lines between hunks. */
15
+ contents?: DiffContents;
16
+ }
@@ -0,0 +1,56 @@
1
+ interface TextBudget {
2
+ readonly rows: number;
3
+ readonly characters: number;
4
+ readonly lineLength: number;
5
+ }
6
+
7
+ // Diff preparation is synchronous; cap source work before creating lines or hunks.
8
+ const SOURCE_BUDGET: TextBudget = { rows: 20000, characters: 2000000, lineLength: 1000000 };
9
+ const PATCH_BUDGET: TextBudget = { rows: 50000, characters: 4000000, lineLength: 1000001 };
10
+ const LEGACY_BUDGET: TextBudget = { rows: 400, characters: 100000, lineLength: 10000 };
11
+
12
+ export function textFitsBudget(text: string, budget: TextBudget): boolean {
13
+ if (text.length > budget.characters) {
14
+ return false;
15
+ }
16
+ let rows = text.length === 0 ? 0 : 1;
17
+ let length = 0;
18
+ for (let index = 0; index < text.length; index++) {
19
+ if (text.charCodeAt(index) === 10) {
20
+ if (index < text.length - 1 && ++rows > budget.rows) {
21
+ return false;
22
+ }
23
+ length = 0;
24
+ } else if (++length > budget.lineLength) {
25
+ return false;
26
+ }
27
+ }
28
+ return rows <= budget.rows;
29
+ }
30
+
31
+ export function diffSourceBudget(before: string, after: string): boolean {
32
+ return textFitsBudget(before, SOURCE_BUDGET) && textFitsBudget(after, SOURCE_BUDGET);
33
+ }
34
+
35
+ export function patchSourceBudget(patch: string): boolean {
36
+ return textFitsBudget(patch, PATCH_BUDGET);
37
+ }
38
+
39
+ export function legacyDiffBudget(before: string, after: string, patch = ''): boolean {
40
+ // Combined sources and patch must fit one small legacy render, including trailing blank lines.
41
+ if (before.length + after.length + patch.length > LEGACY_BUDGET.characters) {
42
+ return false;
43
+ }
44
+ let rows = 2;
45
+ for (const text of [before, after, patch]) {
46
+ if (!textFitsBudget(text, LEGACY_BUDGET)) {
47
+ return false;
48
+ }
49
+ for (let index = 0; index < text.length; index++) {
50
+ if (text.charCodeAt(index) === 10 && ++rows > LEGACY_BUDGET.rows) {
51
+ return false;
52
+ }
53
+ }
54
+ }
55
+ return true;
56
+ }
@@ -0,0 +1,21 @@
1
+ import { diffRows, type DiffDocument } from '@adecore/merge';
2
+
3
+ // Larger rendering tiers need native production measurements before this gate expands.
4
+ export const EDITOR_DIFF_WORK = 4_000_000;
5
+ export const EDITOR_DIFF_LIMITS = { rows: 2000, characters: 2000000, lineLength: 10000 };
6
+
7
+ export function canRenderEditorDiff(document: DiffDocument): boolean {
8
+ let rows = 0;
9
+ let characters = 0;
10
+ for (const row of diffRows(document, 'unified')) {
11
+ if (row.kind === 'gap') {
12
+ continue;
13
+ }
14
+ const text = row.new === undefined ? document.sides.old[row.old!.index]! : document.sides.new[row.new.index]!;
15
+ characters += text.length + (rows > 0 ? 1 : 0);
16
+ if (++rows > EDITOR_DIFF_LIMITS.rows || characters > EDITOR_DIFF_LIMITS.characters || text.length > EDITOR_DIFF_LIMITS.lineLength) {
17
+ return false;
18
+ }
19
+ }
20
+ return true;
21
+ }
@@ -0,0 +1,69 @@
1
+ import type { ChatFileChange } from '@adecore/agent-contracts';
2
+ import type { FileChange } from '../../logic/tools';
3
+ import type { DiffContents } from '../full-diff';
4
+
5
+ export interface ReviewEntry {
6
+ readonly key: string;
7
+ readonly path: string;
8
+ readonly change:
9
+ | { readonly kind: 'edit'; readonly edit: FileChange }
10
+ | { readonly kind: 'patch'; readonly patch: ChatFileChange; readonly contents?: DiffContents };
11
+ }
12
+
13
+ export interface ReviewBudget {
14
+ readonly editors: number;
15
+ readonly rows: number;
16
+ readonly characters: number;
17
+ }
18
+
19
+ export const REVIEW_BUDGET: ReviewBudget = { editors: 3, rows: 2000, characters: 2000000 };
20
+ export type ReviewSlot = 'editor' | 'deferred';
21
+
22
+ export function reviewEntries(patches: readonly ChatFileChange[], edits: readonly FileChange[]): readonly ReviewEntry[] {
23
+ return [
24
+ ...patches.map((patch, index): ReviewEntry => ({ key: `patch:${index}:${patch.path}`, path: patch.path, change: { kind: 'patch', patch } })),
25
+ ...edits.map((edit, index): ReviewEntry => ({ key: `edit:${index}:${edit.path}`, path: edit.path, change: { kind: 'edit', edit } }))
26
+ ];
27
+ }
28
+
29
+ export function reviewSize(entry: ReviewEntry, budget: ReviewBudget = REVIEW_BUDGET): { rows: number; characters: number } {
30
+ const texts = entry.change.kind === 'edit' ? [entry.change.edit.before, entry.change.edit.after] : [entry.change.patch.diff];
31
+ const characters = texts.reduce((sum, text) => sum + text.length, 0);
32
+ if (characters > budget.characters) {
33
+ return { rows: budget.rows + 1, characters };
34
+ }
35
+ let rows = 0;
36
+ for (const text of texts) {
37
+ if (text.length > 0) {
38
+ rows++;
39
+ }
40
+ for (let index = text.indexOf('\n'); index >= 0; index = text.indexOf('\n', index + 1)) {
41
+ if (++rows > budget.rows) {
42
+ return { rows, characters };
43
+ }
44
+ }
45
+ }
46
+ return { rows, characters };
47
+ }
48
+
49
+ export function planReview(entries: readonly ReviewEntry[], promoted: string | null, budget: ReviewBudget = REVIEW_BUDGET): ReadonlyMap<string, ReviewSlot> {
50
+ const priority = entries.find((entry) => entry.key === promoted);
51
+ const ordered = priority === undefined ? entries : [priority, ...entries.filter((entry) => entry !== priority)];
52
+ const slots = new Map<string, ReviewSlot>();
53
+ let editors = 0;
54
+ let rows = 0;
55
+ let characters = 0;
56
+ for (const entry of ordered) {
57
+ const size = reviewSize(entry, budget);
58
+ // The first review keeps its own omission and error handling, even when the group estimate exceeds the budget.
59
+ if (editors === 0 || (editors < budget.editors && rows + size.rows <= budget.rows && characters + size.characters <= budget.characters)) {
60
+ slots.set(entry.key, 'editor');
61
+ editors++;
62
+ rows += size.rows;
63
+ characters += size.characters;
64
+ } else {
65
+ slots.set(entry.key, 'deferred');
66
+ }
67
+ }
68
+ return slots;
69
+ }
@@ -0,0 +1,14 @@
1
+ import { createContext, useContext, useEffect } from 'react';
2
+
3
+ export const ReviewSettleContext = createContext<{ readonly key: string; settle(key: string): void } | null>(null);
4
+
5
+ export function useReviewSettled(): void {
6
+ const scope = useContext(ReviewSettleContext);
7
+ const key = scope?.key;
8
+ const settle = scope?.settle;
9
+ useEffect(() => {
10
+ if (key !== undefined) {
11
+ settle?.(key);
12
+ }
13
+ }, [key, settle]);
14
+ }