@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,222 @@
1
+ import type { Highlighter, ThemeRegistration } from 'shiki';
2
+ import {
3
+ createSyntaxService,
4
+ type DiffSyntaxDocument,
5
+ type DiffSyntaxInput,
6
+ type DiffSyntaxOutput,
7
+ type DiffSyntaxService,
8
+ type DiffSyntaxTokens
9
+ } from '@adecore/editor/shiki-worker';
10
+ import { chatHost } from '../../../host';
11
+
12
+ interface Pending {
13
+ promise: Promise<DiffSyntaxTokens | null>;
14
+ resolve: (value: DiffSyntaxTokens | null) => void;
15
+ resent: boolean;
16
+ }
17
+
18
+ interface Session {
19
+ definition: DiffSyntaxDocument;
20
+ pending: Map<number, Pending>;
21
+ sent: boolean;
22
+ }
23
+
24
+ export interface DiffSyntaxSession {
25
+ request(index: number): Promise<DiffSyntaxTokens | null>;
26
+ cancel(): void;
27
+ dispose(): void;
28
+ }
29
+
30
+ export interface DiffSyntaxRuntime {
31
+ open(document: DiffSyntaxDocument): DiffSyntaxSession;
32
+ dispose(): void;
33
+ }
34
+
35
+ export interface DiffSyntaxRuntimeOptions {
36
+ workerFactory?: () => Worker;
37
+ themes: readonly ThemeRegistration[];
38
+ highlighter?: () => Promise<Highlighter>;
39
+ }
40
+
41
+ let nextGeneration = 0;
42
+ let shared: DiffSyntaxRuntime | undefined;
43
+
44
+ export function diffSyntaxRuntime(): DiffSyntaxRuntime {
45
+ const code = chatHost().code;
46
+ return (shared ??= createDiffSyntaxRuntime({ workerFactory: code.syntaxWorker, themes: code.custom, highlighter: code.highlighter }));
47
+ }
48
+
49
+ export function createDiffSyntaxRuntime(options: DiffSyntaxRuntimeOptions): DiffSyntaxRuntime {
50
+ const sessions = new Map<number, Session>();
51
+ let worker: Worker | undefined;
52
+ let fallback: DiffSyntaxService | undefined;
53
+ let highlighter: Promise<Highlighter> | undefined;
54
+ let started = false;
55
+ let stopped = false;
56
+ const settle = (session: Session): void => {
57
+ for (const pending of session.pending.values()) {
58
+ pending.resolve(null);
59
+ }
60
+ session.pending.clear();
61
+ };
62
+ const stopWorker = (): void => {
63
+ worker?.removeEventListener('message', onMessage);
64
+ worker?.removeEventListener('error', onError);
65
+ worker?.removeEventListener('messageerror', onError);
66
+ worker?.terminate();
67
+ worker = undefined;
68
+ };
69
+ const loadHighlighter = (): Promise<Highlighter> => {
70
+ highlighter ??= import('shiki').then(({ createHighlighter }) => createHighlighter({ langs: [], themes: [] }));
71
+ return highlighter;
72
+ };
73
+ const startFallback = (): void => {
74
+ stopWorker();
75
+ if (fallback !== undefined || stopped) {
76
+ return;
77
+ }
78
+ fallback = createSyntaxService({ highlighter: options.highlighter ?? loadHighlighter, send: receive });
79
+ dispatch({ type: 'start', themes: options.themes, maxLines: 100000 });
80
+ for (const [generation, session] of sessions) {
81
+ session.sent = false;
82
+ for (const index of session.pending.keys()) {
83
+ request(generation, session, index);
84
+ }
85
+ }
86
+ };
87
+ const onError = (event: Event): void => {
88
+ event.preventDefault();
89
+ startFallback();
90
+ };
91
+ const onMessage = (event: MessageEvent<DiffSyntaxOutput>): void => {
92
+ receive(event.data);
93
+ };
94
+ const initialize = (): void => {
95
+ if (started || stopped) {
96
+ return;
97
+ }
98
+ started = true;
99
+ if (options.workerFactory !== undefined) {
100
+ try {
101
+ worker = options.workerFactory();
102
+ worker.addEventListener('message', onMessage);
103
+ worker.addEventListener('error', onError);
104
+ worker.addEventListener('messageerror', onError);
105
+ dispatch({ type: 'start', themes: options.themes, maxLines: 100000 });
106
+ return;
107
+ } catch {
108
+ startFallback();
109
+ }
110
+ } else {
111
+ startFallback();
112
+ }
113
+ };
114
+ const dispatch = (message: DiffSyntaxInput): void => {
115
+ if (stopped) {
116
+ return;
117
+ }
118
+ if (worker !== undefined) {
119
+ try {
120
+ worker.postMessage(message);
121
+ } catch {
122
+ startFallback();
123
+ }
124
+ } else if (fallback !== undefined) {
125
+ void fallback.handle(message).catch(() => {
126
+ for (const session of sessions.values()) {
127
+ settle(session);
128
+ }
129
+ });
130
+ }
131
+ };
132
+ const request = (generation: number, session: Session, index: number, resend = false): void => {
133
+ const document = !session.sent || resend ? session.definition : undefined;
134
+ session.sent = true;
135
+ dispatch({ type: 'request', generation, index, ...(document ? { document } : {}) });
136
+ };
137
+ const receive = (message: DiffSyntaxOutput): void => {
138
+ if (stopped || message.type === 'ready') {
139
+ return;
140
+ }
141
+ if (message.type === 'error') {
142
+ if (worker !== undefined) {
143
+ startFallback();
144
+ } else {
145
+ for (const session of sessions.values()) {
146
+ settle(session);
147
+ }
148
+ }
149
+ return;
150
+ }
151
+ const session = sessions.get(message.generation);
152
+ const pending = session?.pending.get(message.index);
153
+ if (session === undefined || pending === undefined) {
154
+ return;
155
+ }
156
+ if (message.type === 'failed' && message.reason === 'missing' && !pending.resent) {
157
+ pending.resent = true;
158
+ request(message.generation, session, message.index, true);
159
+ return;
160
+ }
161
+ session.pending.delete(message.index);
162
+ pending.resolve(message.type === 'tokens' ? message : null);
163
+ };
164
+ return {
165
+ open(document) {
166
+ const generation = ++nextGeneration;
167
+ const session: Session = { definition: document, pending: new Map(), sent: false };
168
+ sessions.set(generation, session);
169
+ return {
170
+ request(index) {
171
+ if (stopped || !sessions.has(generation)) {
172
+ return Promise.resolve(null);
173
+ }
174
+ const existing = session.pending.get(index);
175
+ if (existing !== undefined) {
176
+ return existing.promise;
177
+ }
178
+ let resolve!: Pending['resolve'];
179
+ const promise = new Promise<DiffSyntaxTokens | null>((done) => {
180
+ resolve = done;
181
+ });
182
+ session.pending.set(index, { promise, resolve, resent: false });
183
+ const initialized = started;
184
+ initialize();
185
+ // Fallback initialization replays pending requests, including this first one.
186
+ if (initialized || worker !== undefined) {
187
+ request(generation, session, index);
188
+ }
189
+ return promise;
190
+ },
191
+ cancel() {
192
+ settle(session);
193
+ if (started) {
194
+ dispatch({ type: 'cancel', generation });
195
+ }
196
+ },
197
+ dispose() {
198
+ settle(session);
199
+ sessions.delete(generation);
200
+ if (started) {
201
+ dispatch({ type: 'dispose', generation });
202
+ }
203
+ }
204
+ };
205
+ },
206
+ dispose() {
207
+ stopped = true;
208
+ for (const session of sessions.values()) {
209
+ settle(session);
210
+ }
211
+ sessions.clear();
212
+ stopWorker();
213
+ fallback?.dispose();
214
+ if (options.highlighter === undefined) {
215
+ void highlighter?.then(
216
+ (loaded) => loaded.dispose(),
217
+ () => undefined
218
+ );
219
+ }
220
+ }
221
+ };
222
+ }
@@ -0,0 +1,38 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { planReview, type ReviewEntry, type ReviewSlot } from './review-plan';
3
+
4
+ export interface ReviewStack {
5
+ readonly slots: ReadonlyMap<string, ReviewSlot>;
6
+ readonly ready: ReadonlySet<string>;
7
+ promote(key: string): void;
8
+ settle(key: string): void;
9
+ }
10
+
11
+ export function useReviewStack(entries: readonly ReviewEntry[]): ReviewStack {
12
+ const [promoted, setPromoted] = useState<string | null>(null);
13
+ const slots = useMemo(() => planReview(entries, promoted), [entries, promoted]);
14
+ const selected = [...slots].filter(([, slot]) => slot === 'editor').map(([key]) => key);
15
+ const [admitted, setAdmitted] = useState<string[]>(() => selected.slice(0, 1));
16
+ const retained = admitted.filter((key) => selected.includes(key));
17
+ const live = retained.length === 0 ? selected.slice(0, 1) : retained;
18
+ if (live.length !== admitted.length || live.some((key, index) => key !== admitted[index])) {
19
+ setAdmitted(live);
20
+ }
21
+ const [settled, setSettled] = useState<ReadonlySet<string>>(() => new Set());
22
+ if ([...settled].some((key) => !selected.includes(key))) {
23
+ setSettled(new Set([...settled].filter((key) => selected.includes(key))));
24
+ }
25
+ const settle = useCallback((key: string) => {
26
+ setSettled((previous) => (previous.has(key) ? previous : new Set([...previous, key])));
27
+ }, []);
28
+ const allSettled = live.every((key) => settled.has(key));
29
+ const next = selected.find((key) => !live.includes(key));
30
+ useEffect(() => {
31
+ if (next === undefined || !allSettled) {
32
+ return;
33
+ }
34
+ const frame = window.requestAnimationFrame(() => setAdmitted((previous) => [...previous, next]));
35
+ return () => window.cancelAnimationFrame(frame);
36
+ }, [next, allSettled]);
37
+ return { slots, ready: new Set(live), promote: setPromoted, settle };
38
+ }
@@ -0,0 +1,82 @@
1
+ import type { LineToken } from '@adecore/editor';
2
+ import { lineTokensOf, type DiffSyntaxDocument } from '@adecore/editor/shiki-worker';
3
+ import type { DiffDocument } from '@adecore/merge';
4
+ import { diffSyntaxRuntime, type DiffSyntaxRuntime } from './diff/syntax-runtime';
5
+
6
+ export { createDiffSyntaxRuntime, diffSyntaxRuntime } from './diff/syntax-runtime';
7
+ export type { DiffSyntaxRuntime, DiffSyntaxRuntimeOptions, DiffSyntaxSession } from './diff/syntax-runtime';
8
+
9
+ export interface DiffSyntaxMapping {
10
+ readonly documents: Readonly<Record<'old' | 'new', DiffSyntaxDocument>>;
11
+ locate(side: 'old' | 'new', index: number): { readonly block: number; readonly line: number } | undefined;
12
+ }
13
+
14
+ export interface DiffSourceTokenSession {
15
+ get(side: 'old' | 'new', index: number): readonly LineToken[] | null | undefined;
16
+ subscribe(listener: () => void): () => void;
17
+ dispose(): void;
18
+ }
19
+
20
+ /* Structurally accepts the editor's source mapping without depending on its React renderer. */
21
+ export function diffSourceTokens(document: DiffDocument, mapping: DiffSyntaxMapping, runtime: DiffSyntaxRuntime = diffSyntaxRuntime()): DiffSourceTokenSession {
22
+ const sides = { old: runtime.open(mapping.documents.old), new: runtime.open(mapping.documents.new) };
23
+ const cache = { old: new Map<number, ReadonlyArray<readonly LineToken[] | null>>(), new: new Map<number, ReadonlyArray<readonly LineToken[] | null>>() };
24
+ const pending = { old: new Set<number>(), new: new Set<number>() };
25
+ const listeners = new Set<() => void>();
26
+ let disposed = false;
27
+ return {
28
+ get(side, index) {
29
+ const location = mapping.locate(side, index);
30
+ if (disposed || location === undefined || document.sides[side][index] === undefined) {
31
+ return null;
32
+ }
33
+ const { block, line } = location;
34
+ const known = cache[side].get(block);
35
+ if (known !== undefined) {
36
+ return known[line] ?? null;
37
+ }
38
+ if (!pending[side].has(block)) {
39
+ pending[side].add(block);
40
+ const text = mapping.documents[side].blocks[block]!.split('\n');
41
+ const publish = (lines: ReadonlyArray<readonly LineToken[] | null>): void => {
42
+ if (disposed) {
43
+ return;
44
+ }
45
+ pending[side].delete(block);
46
+ cache[side].set(block, lines);
47
+ for (const listener of [...listeners]) {
48
+ listener();
49
+ }
50
+ };
51
+ const count = text.length;
52
+ void sides[side].request(block).then(
53
+ (packet) => {
54
+ if (!disposed) {
55
+ publish(text.map((text, line) => (packet === null || packet.lines.length !== count + 1 ? null : lineTokensOf(packet, line, text))));
56
+ }
57
+ },
58
+ () => publish(text.map(() => null))
59
+ );
60
+ }
61
+ return undefined;
62
+ },
63
+ subscribe(listener) {
64
+ if (!disposed) {
65
+ listeners.add(listener);
66
+ }
67
+ return () => {
68
+ listeners.delete(listener);
69
+ };
70
+ },
71
+ dispose() {
72
+ disposed = true;
73
+ listeners.clear();
74
+ sides.old.dispose();
75
+ sides.new.dispose();
76
+ cache.old.clear();
77
+ cache.new.clear();
78
+ pending.old.clear();
79
+ pending.new.clear();
80
+ }
81
+ };
82
+ }
@@ -17,9 +17,11 @@ import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
17
17
  import { Icon, Spinner, useTickingText } from '@adecore/ui';
18
18
  import { ROW_GUTTER, toolIcon } from '../icons';
19
19
  import { AnsiOutput } from '../AnsiOutput';
20
+ import { DiffStack, ReviewStackView } from '../diff/DiffStack';
21
+ import { reviewEntries, type ReviewEntry } from '../diff/review-plan';
22
+ import { useReviewStack } from '../diff/use-review-stack';
20
23
 
21
- // The diff renderers carry shiki; they only load once a thread shows a file change.
22
- const EditDiff = lazyNamed(() => import('../EditDiff'), 'default');
24
+ // A checkpoint opens its renderer only when a file is expanded.
23
25
  const UnifiedDiff = lazyNamed(() => import('../UnifiedDiff'), 'default');
24
26
 
25
27
  const OUTPUT_LIMIT = 4000;
@@ -118,18 +120,8 @@ function ToolBody({ tool }: { tool: ChatToolItem }) {
118
120
  const changes = patches.length > 0 ? [] : fileChanges(tool.name, tool.input);
119
121
  return (
120
122
  <div className="mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised">
121
- {patches.length > 0 ? (
122
- <DiffSuspense>
123
- {patches.map((change, index) => (
124
- <UnifiedDiff key={index} change={change} />
125
- ))}
126
- </DiffSuspense>
127
- ) : changes.length > 0 ? (
128
- <DiffSuspense>
129
- {changes.map((change, index) => (
130
- <EditDiff key={index} change={change} />
131
- ))}
132
- </DiffSuspense>
123
+ {patches.length + changes.length > 0 ? (
124
+ <DiffStack entries={reviewEntries(patches, changes)} />
133
125
  ) : (
134
126
  <pre className="max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text">
135
127
  {clip(JSON.stringify(tool.input, null, 2) ?? '')}
@@ -416,6 +408,36 @@ export function ChangedFilesRow({
416
408
  return <ProviderChangedFiles tools={tools} open={open} setOpen={setOpen} revealNonce={revealNonce} />;
417
409
  }
418
410
 
411
+ interface FileReviews {
412
+ readonly path: string;
413
+ readonly count: number;
414
+ readonly reviews: readonly ReviewEntry[];
415
+ }
416
+
417
+ function ProviderReviewFiles({
418
+ reviews,
419
+ open,
420
+ setOpen
421
+ }: {
422
+ reviews: readonly FileReviews[];
423
+ open: Record<string, boolean>;
424
+ setOpen: Dispatch<SetStateAction<Record<string, boolean>>>;
425
+ }) {
426
+ const { t } = useTranslation('agent-chat');
427
+ const stack = useReviewStack(reviews.filter(({ path }) => open[path] === true).flatMap(({ reviews }) => reviews));
428
+ return reviews.map(({ path, count, reviews }) => (
429
+ <ChangedFileLine
430
+ key={path}
431
+ path={path}
432
+ open={open[path] === true}
433
+ onToggle={() => setOpen((previous) => ({ ...previous, [path]: !previous[path] }))}
434
+ trailing={<span className="text-text-faint">{t('work.edits', { count })}</span>}
435
+ >
436
+ <ReviewStackView entries={reviews} stack={stack} />
437
+ </ChangedFileLine>
438
+ ));
439
+ }
440
+
419
441
  /* The fallback card, built from what the CLI itself reported about its edits. */
420
442
  function ProviderChangedFiles({
421
443
  tools,
@@ -428,7 +450,6 @@ function ProviderChangedFiles({
428
450
  revealNonce: number;
429
451
  setOpen: Dispatch<SetStateAction<Record<string, boolean>>>;
430
452
  }) {
431
- const { t } = useTranslation('agent-chat');
432
453
  const byPath = new Map<string, { edits: FileChange[]; patches: ChatFileChange[] }>();
433
454
  const entryFor = (path: string) => {
434
455
  const existing = byPath.get(path);
@@ -451,26 +472,14 @@ function ProviderChangedFiles({
451
472
  entryFor(change.path || tool.id).edits.push(change);
452
473
  }
453
474
  }
475
+ const reviews = [...byPath].map(([path, entry]) => ({
476
+ path,
477
+ count: entry.patches.length + entry.edits.length,
478
+ reviews: reviewEntries(entry.patches, entry.edits)
479
+ }));
454
480
  return (
455
481
  <ChangedFilesCard count={byPath.size} revealNonce={revealNonce}>
456
- {[...byPath].map(([path, entry]) => (
457
- <ChangedFileLine
458
- key={path}
459
- path={path}
460
- open={open[path] === true}
461
- onToggle={() => setOpen((o) => ({ ...o, [path]: !o[path] }))}
462
- trailing={<span className="text-text-faint">{t('work.edits', { count: entry.edits.length + entry.patches.length })}</span>}
463
- >
464
- <DiffSuspense>
465
- {entry.patches.map((change, index) => (
466
- <UnifiedDiff key={`patch-${index}`} change={change} />
467
- ))}
468
- {entry.edits.map((change, index) => (
469
- <EditDiff key={`edit-${index}`} change={change} />
470
- ))}
471
- </DiffSuspense>
472
- </ChangedFileLine>
473
- ))}
482
+ <ProviderReviewFiles reviews={reviews} open={open} setOpen={setOpen} />
474
483
  </ChangedFilesCard>
475
484
  );
476
485
  }
@@ -0,0 +1,43 @@
1
+ import { useLayoutEffect, useMemo } from 'react';
2
+ import { diffSyntaxDocument, type DiffSourceTokens } from '@adecore/editor-react';
3
+ import type { DiffDocument } from '@adecore/merge';
4
+ import { diffSourceTokens, type DiffSourceTokenSession, type DiffSyntaxRuntime } from './diff-syntax';
5
+
6
+ function createSourceBinding(document: DiffDocument, language: string, theme: string, runtime?: DiffSyntaxRuntime) {
7
+ const mapping = diffSyntaxDocument(document, language, theme);
8
+ const listeners = new Set<() => void>();
9
+ let source: DiffSourceTokenSession | undefined;
10
+ const current = (): DiffSourceTokenSession => {
11
+ if (source === undefined) {
12
+ source = diffSourceTokens(document, mapping, runtime);
13
+ source.subscribe(() => {
14
+ for (const listener of [...listeners]) {
15
+ listener();
16
+ }
17
+ });
18
+ }
19
+ return source;
20
+ };
21
+ return {
22
+ tokens: {
23
+ get: (side, index) => (mapping.locate(side, index) === undefined ? null : current().get(side, index)),
24
+ subscribe(listener) {
25
+ listeners.add(listener);
26
+ return () => {
27
+ listeners.delete(listener);
28
+ };
29
+ }
30
+ } satisfies DiffSourceTokens,
31
+ release() {
32
+ // StrictMode replays effects with the same memo; the next read opens fresh sessions.
33
+ source?.dispose();
34
+ source = undefined;
35
+ }
36
+ };
37
+ }
38
+
39
+ export function useDiffSourceTokens(document: DiffDocument, language: string, theme: string, runtime?: DiffSyntaxRuntime): DiffSourceTokens {
40
+ const binding = useMemo(() => createSourceBinding(document, language, theme, runtime), [document, language, theme, runtime]);
41
+ useLayoutEffect(() => () => binding.release(), [binding]);
42
+ return binding.tokens;
43
+ }
package/src/host.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react';
2
2
  import type { Extension } from '@codemirror/state';
3
3
  import type { EditorView } from '@codemirror/view';
4
- import type { ThemeRegistration } from 'shiki';
4
+ import type { Highlighter, ThemeRegistration } from 'shiki';
5
+ import type { EditorEngine } from '@adecore/editor';
5
6
  import type {
6
7
  AgentKind,
7
8
  ChatCheckpointDiff,
@@ -244,6 +245,14 @@ export interface ChatHost {
244
245
  useThemes(): { light: string; dark: string };
245
246
  /* Themes the app ships itself, found by name; the rest Shiki looks up by id. */
246
247
  custom: ReadonlyArray<ThemeRegistration & { name: string }>;
248
+ /* One syntax worker shared by chat diffs and standalone review panes; omitted, tokenize on the page. */
249
+ syntaxWorker?: () => Worker;
250
+ /* Starts legacy highlighting with its first diff; an enclosing DiffPool takes precedence. */
251
+ legacyDiffWorker?: () => Worker;
252
+ /* Borrow the page highlighter for fallback; the host retains ownership. */
253
+ highlighter?: () => Promise<Highlighter>;
254
+ /* Opt in to bounded editor reviews; failures show a status, omitted keeps the legacy renderer. */
255
+ editor?: () => Promise<EditorEngine>;
247
256
  };
248
257
  /* How a reply appears while it streams: a word, a block or the whole of it at a time. */
249
258
  useStreaming(): 'words' | 'blocks' | 'whole';
package/src/lazy.tsx CHANGED
@@ -35,7 +35,8 @@ export function onLazyOpenError(listener: (error: unknown) => void): () => void
35
35
  */
36
36
  export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(
37
37
  load: () => Promise<Module>,
38
- name: Name
38
+ name: Name,
39
+ options?: { readonly prefetch?: boolean }
39
40
  ): ComponentType<ComponentProps<Module[Name]>> {
40
41
  let loaded: ComponentType<any> | null = null;
41
42
  const open = async (): Promise<ComponentType<any>> => {
@@ -43,8 +44,10 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
43
44
  loaded ??= component;
44
45
  return component;
45
46
  };
46
- loaders.push(open);
47
- register?.(open);
47
+ if (options?.prefetch !== false) {
48
+ loaders.push(open);
49
+ register?.(open);
50
+ }
48
51
  // Only a render loads through here; a prefetch calls `open` itself, so its failures report nothing.
49
52
  const Lazy = lazy(async () => {
50
53
  try {
@@ -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,17 +1,15 @@
1
- import { Suspense, useState } from 'react';
1
+ import { useState } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { ChatApprovalItem } from '@adecore/agent-contracts';
5
5
  import { approvalChanges, fileChanges } from '../../chat/logic/tools';
6
6
  import { chatHost } from '../../host';
7
- import { lazyNamed } from '../../lazy';
7
+ import { ApprovalReview } from '../../chat/ui/diff/ApprovalReview';
8
+ import { reviewEntries } from '../../chat/ui/diff/review-plan';
8
9
  import { isPrimaryKey } from '../logic/keys';
9
10
  import type { PromptDraft } from '../logic/prompts';
10
11
  import { Button, TextMenu } from '@adecore/ui';
11
12
 
12
- const EditDiff = lazyNamed(() => import('../../chat/ui/EditDiff'), 'default');
13
- const UnifiedDiff = lazyNamed(() => import('../../chat/ui/UnifiedDiff'), 'default');
14
-
15
13
  /* A command in the sunken box a person reads before allowing it, with the folder it runs in when that is known. */
16
14
  export function CommandBox({ command, cwd }: { command: string; cwd?: string }) {
17
15
  return (
@@ -27,6 +25,9 @@ function ApprovalDetails({ item }: { item: ChatApprovalItem }) {
27
25
  const [expanded, setExpanded] = useState(false);
28
26
  const patches = approvalChanges(item.input);
29
27
  const edits = patches.length ? [] : fileChanges(item.toolName, item.input);
28
+ const entries = reviewEntries(patches, edits);
29
+ const editorPreview = chatHost().code.editor !== undefined;
30
+ const maxHeight = expanded ? 480 : 176;
30
31
  const input = typeof item.input === 'object' && item.input !== null ? (item.input as Record<string, unknown>) : {};
31
32
  return (
32
33
  <>
@@ -36,15 +37,8 @@ function ApprovalDetails({ item }: { item: ChatApprovalItem }) {
36
37
  )}
37
38
  {patches.length + edits.length > 0 ? (
38
39
  <div className="overflow-hidden rounded-xl bg-surface-sunken">
39
- <div className={clsx('overflow-auto', !expanded && 'max-h-44')}>
40
- <Suspense fallback={<p className="p-3 text-xs text-text-muted">{t('approval.loadingDiff')}</p>}>
41
- {patches.map((change, i) => (
42
- <UnifiedDiff key={i} change={change} />
43
- ))}
44
- {edits.map((change, i) => (
45
- <EditDiff key={i} change={change} />
46
- ))}
47
- </Suspense>
40
+ <div className={clsx(editorPreview ? 'overflow-hidden' : 'overflow-auto', !editorPreview && !expanded && 'max-h-44')}>
41
+ <ApprovalReview key={item.requestId} entries={entries} maxHeight={maxHeight} />
48
42
  </div>
49
43
  <div className="flex items-center border-t border-border p-1">
50
44
  <Button size="sm" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>