vzcode 2.21.0 → 2.22.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 (40) hide show
  1. package/README.md +359 -0
  2. package/dist/assets/{index-D6-he0oi.js → index-CfDs-dAu.js} +99 -99
  3. package/dist/assets/{index-fYq6iaqF.css → index-fSroLVgi.css} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist/llm-streaming-server/aiEditing.js +58 -0
  6. package/dist/llm-streaming-server/chatOperations.js +494 -0
  7. package/dist/llm-streaming-server/errorHandling.js +49 -0
  8. package/dist/llm-streaming-server/index.js +20 -0
  9. package/dist/llm-streaming-server/llmStreaming.js +265 -0
  10. package/dist/llm-streaming-server/validation.js +19 -0
  11. package/dist/server/aiChatHandler/index.js +5 -5
  12. package/package.json +35 -35
  13. package/src/client/CodeEditor/getOrCreateEditor.tsx +20 -13
  14. package/src/client/CodeEditor/index.tsx +3 -3
  15. package/src/client/VZSidebar/AIChat/styles.scss +38 -0
  16. package/src/client/VZSidebar/FileTypeIcon.tsx +1 -0
  17. package/src/client/VZSidebar/index.tsx +1 -1
  18. package/src/llm-streaming-server/README.md +71 -0
  19. package/src/llm-streaming-server/aiEditing.ts +102 -0
  20. package/src/llm-streaming-server/chatOperations.ts +655 -0
  21. package/src/llm-streaming-server/errorHandling.ts +66 -0
  22. package/src/llm-streaming-server/index.ts +51 -0
  23. package/src/llm-streaming-server/llmStreaming.ts +403 -0
  24. package/src/llm-streaming-server/validation.ts +24 -0
  25. package/src/llm-streaming-ui/README.md +103 -0
  26. package/src/llm-streaming-ui/components/ChatInput.tsx +237 -0
  27. package/src/llm-streaming-ui/components/DiffView.scss +173 -0
  28. package/src/llm-streaming-ui/components/DiffView.tsx +236 -0
  29. package/src/llm-streaming-ui/components/FileEditingIndicator.tsx +89 -0
  30. package/src/llm-streaming-ui/components/IndividualFileDiff.tsx +86 -0
  31. package/src/llm-streaming-ui/components/JumpToLatestButton.tsx +64 -0
  32. package/src/llm-streaming-ui/components/Message.tsx +145 -0
  33. package/src/llm-streaming-ui/components/MessageList.tsx +241 -0
  34. package/src/llm-streaming-ui/components/ThinkingScratchpad.tsx +42 -0
  35. package/src/llm-streaming-ui/components/TypingIndicator.tsx +19 -0
  36. package/src/llm-streaming-ui/components/index.tsx +388 -0
  37. package/src/llm-streaming-ui/components/styles.scss +831 -0
  38. package/src/llm-streaming-ui/components/useSpeechRecognition.ts +116 -0
  39. package/src/llm-streaming-ui/index.ts +34 -0
  40. package/src/server/aiChatHandler/index.ts +5 -5
@@ -0,0 +1,237 @@
1
+ import {
2
+ useRef,
3
+ useEffect,
4
+ useCallback,
5
+ memo,
6
+ } from 'react';
7
+ import {
8
+ Form,
9
+ Button,
10
+ ButtonGroup,
11
+ ToggleButton,
12
+ } from '../../client/bootstrap';
13
+ import { enableAskMode } from '../../client/featureFlags';
14
+ import { useSpeechRecognition } from './useSpeechRecognition';
15
+ import { MicSVG, MicOffSVG } from '../../client/Icons';
16
+
17
+ interface ChatInputProps {
18
+ aiChatMessage: string;
19
+ setAIChatMessage: (message: string) => void;
20
+ onSendMessage: () => void;
21
+ focused: boolean;
22
+ aiChatMode: 'ask' | 'edit';
23
+ setAIChatMode: (mode: 'ask' | 'edit') => void;
24
+ navigateMessageHistoryUp: () => void;
25
+ navigateMessageHistoryDown: () => void;
26
+ resetMessageHistoryNavigation: () => void;
27
+ }
28
+
29
+ const ChatInputComponent = ({
30
+ aiChatMessage,
31
+ setAIChatMessage,
32
+ onSendMessage,
33
+ focused,
34
+ aiChatMode,
35
+ setAIChatMode,
36
+ navigateMessageHistoryUp,
37
+ navigateMessageHistoryDown,
38
+ resetMessageHistoryNavigation,
39
+ }: ChatInputProps) => {
40
+ const inputRef = useRef<HTMLTextAreaElement>(null);
41
+
42
+ // Use the speech recognition hook
43
+ const {
44
+ isSpeaking,
45
+ toggleSpeechRecognition,
46
+ stopSpeaking,
47
+ } = useSpeechRecognition(setAIChatMessage);
48
+
49
+ useEffect(() => {
50
+ // Focus the input when the AI chat is focused
51
+ if (focused && inputRef.current) {
52
+ inputRef.current.focus();
53
+ }
54
+ }, [focused]);
55
+
56
+ const handleKeyDown = useCallback(
57
+ (event: React.KeyboardEvent) => {
58
+ if (event.key === 'Enter' && !event.shiftKey) {
59
+ event.preventDefault();
60
+ onSendMessage();
61
+ } else if (event.key === 'Enter' && event.shiftKey) {
62
+ // Shift+Enter should add a newline, not trigger run code
63
+ // We prevent the event from bubbling up to the global keyboard handler
64
+ event.stopPropagation();
65
+ } else if (event.key === 'ArrowUp') {
66
+ // Only navigate history if cursor is at the beginning of the first line
67
+ const textarea =
68
+ event.target as HTMLTextAreaElement;
69
+ const { selectionStart, value } = textarea;
70
+ const lines = value
71
+ .substring(0, selectionStart)
72
+ .split('\n');
73
+
74
+ // Check if we're at the beginning of the first line
75
+ if (lines.length === 1 && selectionStart === 0) {
76
+ event.preventDefault();
77
+ navigateMessageHistoryUp();
78
+ }
79
+ } else if (event.key === 'ArrowDown') {
80
+ // Only navigate history if cursor is at the end of the last line
81
+ const textarea =
82
+ event.target as HTMLTextAreaElement;
83
+ const { selectionStart, value } = textarea;
84
+ const remainingText =
85
+ value.substring(selectionStart);
86
+ const remainingLines = remainingText.split('\n');
87
+
88
+ // Check if we're at the end of the last line
89
+ if (
90
+ remainingLines.length === 1 &&
91
+ remainingLines[0] === ''
92
+ ) {
93
+ event.preventDefault();
94
+ navigateMessageHistoryDown();
95
+ }
96
+ }
97
+ },
98
+ [
99
+ onSendMessage,
100
+ navigateMessageHistoryUp,
101
+ navigateMessageHistoryDown,
102
+ ],
103
+ );
104
+
105
+ const handleSendClick = useCallback(() => {
106
+ // If speech recognition is active, stop it
107
+ if (isSpeaking) {
108
+ stopSpeaking();
109
+ }
110
+ onSendMessage();
111
+ }, [onSendMessage, isSpeaking, stopSpeaking]);
112
+
113
+ const handleChange = useCallback(
114
+ (event: React.ChangeEvent<HTMLTextAreaElement>) => {
115
+ setAIChatMessage(event.target.value);
116
+ // Reset history navigation when user starts typing
117
+ resetMessageHistoryNavigation();
118
+ },
119
+ [setAIChatMessage, resetMessageHistoryNavigation],
120
+ );
121
+
122
+ return (
123
+ <div className="ai-chat-input-container">
124
+ {enableAskMode && (
125
+ <div
126
+ className="ai-chat-mode-toggle"
127
+ style={{ marginBottom: '8px' }}
128
+ >
129
+ <ButtonGroup size="sm">
130
+ <ToggleButton
131
+ id="ai-chat-mode-ask"
132
+ type="radio"
133
+ variant={
134
+ aiChatMode === 'ask'
135
+ ? 'primary'
136
+ : 'outline-primary'
137
+ }
138
+ name="ai-chat-mode"
139
+ value="ask"
140
+ checked={aiChatMode === 'ask'}
141
+ onChange={() => setAIChatMode('ask')}
142
+ >
143
+ 💬 Ask
144
+ </ToggleButton>
145
+ <ToggleButton
146
+ id="ai-chat-mode-edit"
147
+ type="radio"
148
+ variant={
149
+ aiChatMode === 'edit'
150
+ ? 'primary'
151
+ : 'outline-primary'
152
+ }
153
+ name="ai-chat-mode"
154
+ value="edit"
155
+ checked={aiChatMode === 'edit'}
156
+ onChange={() => setAIChatMode('edit')}
157
+ >
158
+ ✏️ Edit
159
+ </ToggleButton>
160
+ </ButtonGroup>
161
+ <div className="ai-chat-mode-description">
162
+ {aiChatMode === 'ask'
163
+ ? 'Ask questions without editing files'
164
+ : 'Get answers and code edits'}
165
+ </div>
166
+ </div>
167
+ )}
168
+ <Form.Group className="ai-chat-input-group">
169
+ <Form.Control
170
+ as="textarea"
171
+ rows={10}
172
+ value={aiChatMessage}
173
+ onChange={handleChange}
174
+ onKeyDown={handleKeyDown}
175
+ ref={inputRef}
176
+ placeholder={
177
+ aiChatMode === 'edit'
178
+ ? 'Ask me to make code changes...'
179
+ : 'Ask me anything about your code...'
180
+ }
181
+ spellCheck="false"
182
+ aria-label="Chat message input"
183
+ />
184
+ <div className="ai-chat-input-footer">
185
+ <span className="ai-chat-hint">
186
+ {aiChatMessage ? 'Press Enter to send' : ''}
187
+ </span>
188
+ <div style={{ display: 'flex', gap: '8px' }}>
189
+ <Button
190
+ variant={
191
+ isSpeaking ? 'danger' : 'outline-secondary'
192
+ }
193
+ size="sm"
194
+ onClick={toggleSpeechRecognition}
195
+ aria-label={
196
+ isSpeaking
197
+ ? 'Stop voice typing'
198
+ : 'Start voice typing'
199
+ }
200
+ title={
201
+ isSpeaking
202
+ ? 'Stop voice typing'
203
+ : 'Start voice typing'
204
+ }
205
+ style={{
206
+ borderColor: isSpeaking
207
+ ? undefined
208
+ : '#dee2e6',
209
+ borderRadius: '0.375rem',
210
+ borderWidth: '1px',
211
+ borderStyle: 'solid',
212
+ }}
213
+ >
214
+ {isSpeaking ? <MicOffSVG /> : <MicSVG />}
215
+ </Button>
216
+ <Button
217
+ variant={
218
+ aiChatMessage.trim()
219
+ ? 'primary'
220
+ : 'outline-secondary'
221
+ }
222
+ onClick={handleSendClick}
223
+ disabled={!aiChatMessage.trim()}
224
+ className="ai-chat-send-button"
225
+ aria-label="Send message"
226
+ title="Send message (Enter)"
227
+ >
228
+ Send
229
+ </Button>
230
+ </div>
231
+ </div>
232
+ </Form.Group>
233
+ </div>
234
+ );
235
+ };
236
+
237
+ export const ChatInput = memo(ChatInputComponent);
@@ -0,0 +1,173 @@
1
+ .diff-view {
2
+ margin: 12px 0;
3
+
4
+ .diff-summary {
5
+ margin-bottom: 8px;
6
+ display: flex;
7
+ justify-content: space-between;
8
+ align-items: center;
9
+
10
+ .diff-stats {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 8px;
14
+ font-size: 14px;
15
+ color: var(--bs-secondary-color);
16
+
17
+ .files-changed {
18
+ color: white;
19
+ font-weight: 600;
20
+ }
21
+
22
+ .additions {
23
+ color: #28a745;
24
+ font-weight: 600;
25
+ }
26
+
27
+ .deletions {
28
+ color: #dc3545;
29
+ font-weight: 600;
30
+ }
31
+ }
32
+ }
33
+
34
+ .diff-files {
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: 4px;
38
+
39
+ // Fix diff2html styling issues for dark theme and layout
40
+ .d2h-wrapper {
41
+ // Override diff2html variables for dark theme
42
+ --d2h-bg-color: rgb(41, 44, 52);
43
+ --d2h-color: #e6edf3;
44
+ --d2h-border-color: #30363d;
45
+ --d2h-dim-color: #6e7681;
46
+ --d2h-info-bg-color: #161b22;
47
+ --d2h-file-header-bg-color: #161b22;
48
+ --d2h-file-header-border-color: #30363d;
49
+ --d2h-line-border-color: #21262d;
50
+ --d2h-ins-bg-color: rgba(46, 160, 67, 0.15);
51
+ --d2h-ins-border-color: rgba(46, 160, 67, 0.4);
52
+ --d2h-ins-highlight-bg-color: rgba(46, 160, 67, 0.4);
53
+ --d2h-ins-label-color: #3fb950;
54
+ --d2h-del-bg-color: rgba(248, 81, 73, 0.1);
55
+ --d2h-del-border-color: rgba(248, 81, 73, 0.4);
56
+ --d2h-del-highlight-bg-color: rgba(248, 81, 73, 0.4);
57
+ --d2h-del-label-color: #f85149;
58
+
59
+ background-color: var(--d2h-bg-color);
60
+ color: var(--d2h-color);
61
+ border-radius: 6px;
62
+ overflow: hidden;
63
+ }
64
+
65
+ // Fix layout issues with line numbers
66
+ .d2h-diff-table {
67
+ position: relative;
68
+ background-color: var(--d2h-bg-color);
69
+ }
70
+
71
+ .d2h-code-linenumber {
72
+ position: relative !important;
73
+ float: none !important;
74
+ display: table-cell !important;
75
+ width: auto !important;
76
+ background-color: var(--d2h-bg-color) !important;
77
+ border-color: var(--d2h-line-border-color) !important;
78
+ color: var(--d2h-dim-color) !important;
79
+ }
80
+
81
+ .d2h-code-side-linenumber {
82
+ position: relative !important;
83
+ float: none !important;
84
+ display: table-cell !important;
85
+ width: auto !important;
86
+ background-color: var(--d2h-bg-color) !important;
87
+ border-color: var(--d2h-line-border-color) !important;
88
+ color: var(--d2h-dim-color) !important;
89
+ }
90
+
91
+ // Ensure proper table layout
92
+ .d2h-diff-tbody tr {
93
+ display: table-row;
94
+ }
95
+
96
+ .d2h-diff-tbody td {
97
+ display: table-cell;
98
+ vertical-align: top;
99
+ }
100
+
101
+ // Fix file header styling
102
+ .d2h-file-header {
103
+ display: none !important;
104
+ background-color: var(
105
+ --d2h-file-header-bg-color
106
+ ) !important;
107
+ border-bottom: 1px solid
108
+ var(--d2h-file-header-border-color) !important;
109
+ color: var(--d2h-color);
110
+ }
111
+
112
+ // Ensure proper line styling
113
+ .d2h-del {
114
+ background-color: var(--d2h-del-bg-color) !important;
115
+ border-color: var(--d2h-del-border-color) !important;
116
+ }
117
+
118
+ .d2h-ins {
119
+ background-color: var(--d2h-ins-bg-color) !important;
120
+ border-color: var(--d2h-ins-border-color) !important;
121
+ }
122
+
123
+ // Style clickable file names
124
+ .d2h-file-name {
125
+ &:hover {
126
+ text-decoration: underline !important;
127
+ opacity: 0.8;
128
+ }
129
+ }
130
+
131
+ // Fix wrapper border
132
+ .d2h-file-wrapper {
133
+ border: 1px solid var(--d2h-border-color) !important;
134
+ background-color: var(--d2h-bg-color);
135
+ }
136
+ }
137
+
138
+ .deleted-file {
139
+ margin-bottom: 8px;
140
+ border: 1px solid #30363d;
141
+ border-radius: 6px;
142
+ background-color: rgb(41, 44, 52);
143
+ overflow: hidden;
144
+
145
+ .deleted-file-header {
146
+ display: flex;
147
+ justify-content: space-between;
148
+ align-items: center;
149
+ padding: 12px 16px;
150
+ background-color: #161b22;
151
+ border-bottom: 1px solid #30363d;
152
+
153
+ .deleted-file-name {
154
+ color: #e6edf3;
155
+ font-weight: 600;
156
+ font-family:
157
+ 'SFMono-Regular', Consolas, 'Liberation Mono',
158
+ Menlo, monospace;
159
+ font-size: 14px;
160
+ }
161
+
162
+ .deleted-file-status {
163
+ color: #f85149;
164
+ font-weight: 600;
165
+ font-size: 14px;
166
+ background-color: rgba(248, 81, 73, 0.1);
167
+ padding: 4px 8px;
168
+ border-radius: 4px;
169
+ border: 1px solid rgba(248, 81, 73, 0.4);
170
+ }
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,236 @@
1
+ import React, {
2
+ useContext,
3
+ useEffect,
4
+ useRef,
5
+ forwardRef,
6
+ useImperativeHandle,
7
+ } from 'react';
8
+ import {
9
+ UnifiedFilesDiff,
10
+ parseUnifiedDiffStats,
11
+ combineUnifiedDiffs,
12
+ isFileDeletion,
13
+ getDeletedFileName,
14
+ } from '../../utils/fileDiff';
15
+ import * as Diff2Html from 'diff2html';
16
+ import 'diff2html/bundles/css/diff2html.min.css';
17
+ import './DiffView.scss';
18
+ import { VZCodeContext } from '../../client/VZCodeContext';
19
+ import {
20
+ scrollToFirstDiff,
21
+ getHeaderOffset,
22
+ announceDiffSummary,
23
+ } from '../../client/utils/scrollUtils';
24
+ import { getFileId } from '@vizhub/viz-utils';
25
+
26
+ interface DiffViewProps {
27
+ diffData: UnifiedFilesDiff;
28
+ }
29
+
30
+ // Methods that can be called on DiffView from parent components
31
+ export interface DiffViewRef {
32
+ scrollToFirstHunk: () => void;
33
+ focusDiffContainer: () => void;
34
+ announceSummary: () => void;
35
+ getFirstHunkElement: () => HTMLElement | null;
36
+ }
37
+
38
+ export const DiffView = forwardRef<
39
+ DiffViewRef,
40
+ DiffViewProps
41
+ >(({ diffData }, ref) => {
42
+ const { content, openTab, setIsAIChatOpen } =
43
+ useContext(VZCodeContext);
44
+ const diffContainerRef = useRef<HTMLDivElement>(null);
45
+
46
+ const allDiffs = Object.values(diffData).filter(
47
+ (diff) => diff.length > 0,
48
+ );
49
+
50
+ // Separate deleted files from regular diffs
51
+ const deletedFiles: string[] = [];
52
+ const regularDiffs: string[] = [];
53
+
54
+ for (const diff of allDiffs) {
55
+ if (isFileDeletion(diff)) {
56
+ deletedFiles.push(diff);
57
+ } else {
58
+ regularDiffs.push(diff);
59
+ }
60
+ }
61
+
62
+ // Calculate statistics from regular unified diffs only
63
+ let totalAdditions = 0;
64
+ let totalDeletions = 0;
65
+
66
+ for (const unifiedDiff of regularDiffs) {
67
+ const stats = parseUnifiedDiffStats(unifiedDiff);
68
+ totalAdditions += stats.additions;
69
+ totalDeletions += stats.deletions;
70
+ }
71
+
72
+ // Combine regular unified diffs and convert to HTML using diff2html
73
+ const combinedUnifiedDiff = combineUnifiedDiffs(diffData);
74
+ const diffHtml = Diff2Html.html(combinedUnifiedDiff, {
75
+ drawFileList: false,
76
+ matching: 'words',
77
+ diffStyle: 'word',
78
+ outputFormat: 'line-by-line',
79
+ });
80
+
81
+ // Expose methods for parent components to control scrolling and focus
82
+ useImperativeHandle(
83
+ ref,
84
+ () => ({
85
+ scrollToFirstHunk: () => {
86
+ if (diffContainerRef.current) {
87
+ const headerOffset = getHeaderOffset();
88
+ scrollToFirstDiff(
89
+ diffContainerRef.current,
90
+ headerOffset,
91
+ );
92
+ }
93
+ },
94
+ focusDiffContainer: () => {
95
+ if (diffContainerRef.current) {
96
+ diffContainerRef.current.tabIndex = -1;
97
+ diffContainerRef.current.focus();
98
+ }
99
+ },
100
+ announceSummary: () => {
101
+ if (diffContainerRef.current) {
102
+ announceDiffSummary(diffContainerRef.current);
103
+ }
104
+ },
105
+ getFirstHunkElement: () => {
106
+ if (diffContainerRef.current) {
107
+ return diffContainerRef.current.querySelector(
108
+ '.d2h-diff-tbody tr',
109
+ );
110
+ }
111
+ return null;
112
+ },
113
+ }),
114
+ [],
115
+ );
116
+
117
+ // Add click handlers to file names after HTML is rendered
118
+ useEffect(() => {
119
+ if (!diffContainerRef.current) return;
120
+
121
+ const handleFileNameClick = (event: MouseEvent) => {
122
+ const target = event.target as HTMLElement;
123
+ if (target.classList.contains('d2h-file-name')) {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+
127
+ console.log('File name clicked');
128
+
129
+ const fileName = target.textContent?.trim();
130
+
131
+ console.log('Clicked file name:', fileName);
132
+ if (fileName && content) {
133
+ const fileId = getFileId(content, fileName);
134
+
135
+ if (fileId) {
136
+ // Open the file tab and switch to files view
137
+ openTab({ fileId, isTransient: false });
138
+ setIsAIChatOpen(false);
139
+ }
140
+ }
141
+ }
142
+ };
143
+
144
+ const container = diffContainerRef.current;
145
+ const fileNameElements = container.querySelectorAll(
146
+ '.d2h-file-name',
147
+ );
148
+
149
+ // Add click event listeners and cursor pointer style
150
+ fileNameElements.forEach((element) => {
151
+ element.addEventListener(
152
+ 'click',
153
+ handleFileNameClick,
154
+ );
155
+ (element as HTMLElement).style.cursor = 'pointer';
156
+ (element as HTMLElement).style.textDecoration =
157
+ 'underline';
158
+ (element as HTMLElement).style.color = '#58a6ff'; // GitHub blue link color
159
+ });
160
+
161
+ return () => {
162
+ // Cleanup event listeners
163
+ fileNameElements.forEach((element) => {
164
+ element.removeEventListener(
165
+ 'click',
166
+ handleFileNameClick,
167
+ );
168
+ });
169
+ };
170
+ }, [diffHtml, content, openTab, setIsAIChatOpen]);
171
+
172
+ if (allDiffs.length === 0) {
173
+ return null;
174
+ }
175
+
176
+ return (
177
+ <div className="diff-view">
178
+ <div className="diff-summary" id="diff-summary">
179
+ <div className="diff-stats">
180
+ <span className="files-changed">
181
+ {allDiffs.length} file
182
+ {allDiffs.length !== 1 ? 's' : ''} changed
183
+ </span>
184
+ {totalAdditions > 0 && (
185
+ <span className="additions">
186
+ +{totalAdditions}
187
+ </span>
188
+ )}
189
+ {totalDeletions > 0 && (
190
+ <span className="deletions">
191
+ -{totalDeletions}
192
+ </span>
193
+ )}
194
+ {deletedFiles.length > 0 && (
195
+ <span className="deletions">
196
+ {deletedFiles.length} deleted
197
+ </span>
198
+ )}
199
+ </div>
200
+ </div>
201
+
202
+ {/* Render deleted files */}
203
+ {deletedFiles.map((deletionMarker, index) => {
204
+ const fileName = getDeletedFileName(deletionMarker);
205
+ return (
206
+ <div key={index} className="deleted-file">
207
+ <div className="deleted-file-header">
208
+ <span className="deleted-file-name">
209
+ {fileName}
210
+ </span>
211
+ <span className="deleted-file-status">
212
+ File deleted
213
+ </span>
214
+ </div>
215
+ </div>
216
+ );
217
+ })}
218
+
219
+ {/* Render regular diffs */}
220
+ {regularDiffs.length > 0 && (
221
+ <div
222
+ className="diff-files"
223
+ ref={diffContainerRef}
224
+ tabIndex={-1}
225
+ role="region"
226
+ aria-label="Code diff content"
227
+ aria-describedby="diff-summary"
228
+ dangerouslySetInnerHTML={{ __html: diffHtml }}
229
+ />
230
+ )}
231
+ </div>
232
+ );
233
+ });
234
+
235
+ // Add display name for debugging
236
+ DiffView.displayName = 'DiffView';