vzcode 2.15.0 → 2.16.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 (45) hide show
  1. package/dist/assets/{index-BpUpNto4.js → index-C2BLPyQP.js} +138 -125
  2. package/dist/assets/{index-DLm5FQ0E.css → index-DVR90LwF.css} +1 -1
  3. package/dist/index.html +2 -2
  4. package/dist/server/aiChatHandler/chatOperations.js +168 -35
  5. package/dist/server/aiChatHandler/index.js +11 -30
  6. package/dist/server/aiChatHandler/llmStreaming.js +105 -118
  7. package/package.json +11 -11
  8. package/src/client/AIAssist/AIAssistWidget/index.tsx +12 -1
  9. package/src/client/App/useShareDB.ts +1 -1
  10. package/src/client/CodeEditor/index.tsx +9 -10
  11. package/src/client/VZCodeContext/types.ts +3 -1
  12. package/src/client/VZCodeContext/useVZCodeState.ts +7 -5
  13. package/src/client/VZRight.tsx +10 -2
  14. package/src/client/VZSidebar/AIChat/ChatInput.tsx +1 -7
  15. package/src/client/VZSidebar/AIChat/DiffView.scss +1 -0
  16. package/src/client/VZSidebar/AIChat/DiffView.tsx +72 -29
  17. package/src/client/VZSidebar/AIChat/FileEditingIndicator.tsx +81 -0
  18. package/src/client/VZSidebar/AIChat/IndividualFileDiff.tsx +86 -0
  19. package/src/client/VZSidebar/AIChat/JumpToLatestButton.tsx +64 -0
  20. package/src/client/VZSidebar/AIChat/Message.tsx +68 -53
  21. package/src/client/VZSidebar/AIChat/MessageList.tsx +139 -118
  22. package/src/client/VZSidebar/AIChat/StreamingMessage.tsx +63 -21
  23. package/src/client/VZSidebar/AIChat/ThinkingScratchpad.tsx +4 -118
  24. package/src/client/VZSidebar/AIChat/index.tsx +62 -19
  25. package/src/client/VZSidebar/AIChat/styles.scss +159 -16
  26. package/src/client/VZSidebar/Item.tsx +2 -2
  27. package/src/client/VZSidebar/Search.tsx +13 -6
  28. package/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +39 -23
  29. package/src/client/VZSidebar/VisualEditor/utils.ts +1 -1
  30. package/src/client/VZSidebar/index.tsx +12 -10
  31. package/src/client/VZSidebar/useDragAndDrop.tsx +225 -214
  32. package/src/client/featureFlags.ts +3 -0
  33. package/src/client/hooks/useAutoScroll.ts +329 -0
  34. package/src/client/tabsSearchParameters.ts +1 -17
  35. package/src/client/useFileCRUD.ts +5 -2
  36. package/src/client/useKeyboardShortcuts.ts +7 -0
  37. package/src/client/useOpenDirectories.ts +2 -1
  38. package/src/client/usePrettier/index.ts +1 -1
  39. package/src/client/useURLSync.ts +1 -0
  40. package/src/client/utils/scrollUtils.ts +170 -0
  41. package/src/client/vzReducer/searchReducer.ts +6 -3
  42. package/src/server/aiChatHandler/chatOperations.ts +224 -43
  43. package/src/server/aiChatHandler/index.ts +8 -48
  44. package/src/server/aiChatHandler/llmStreaming.ts +149 -170
  45. package/src/types.ts +81 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vzcode",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -120,8 +120,8 @@
120
120
  "@codemirror/state": "^6.5.2",
121
121
  "@codemirror/theme-one-dark": "^6.1.3",
122
122
  "@codemirror/view": "^6.38.2",
123
- "@langchain/core": "^0.3.75",
124
- "@langchain/openai": "^0.6.11",
123
+ "@langchain/core": "^0.3.77",
124
+ "@langchain/openai": "^0.6.13",
125
125
  "@lezer/highlight": "^1.2.1",
126
126
  "@livekit/components-react": "^2.9.14",
127
127
  "@replit/codemirror-indentation-markers": "^6.5.3",
@@ -172,7 +172,7 @@
172
172
  "react-bootstrap": "^2.10.10",
173
173
  "react-dom": "^18",
174
174
  "react-markdown": "^10.1.0",
175
- "react-router-dom": "^7.8.2",
175
+ "react-router-dom": "^7.9.1",
176
176
  "remark-gfm": "^4.0.1",
177
177
  "sharedb": "^5.2.2",
178
178
  "sharedb-client-browser": "^5.2.2",
@@ -186,9 +186,9 @@
186
186
  "@types/d3-color": "^3.1.3",
187
187
  "@types/react": "^18",
188
188
  "@types/react-dom": "^18",
189
- "@typescript-eslint/eslint-plugin": "^8.43.0",
190
- "@typescript-eslint/parser": "^8.43.0",
191
- "@vitejs/plugin-react": "^5.0.2",
189
+ "@typescript-eslint/eslint-plugin": "^8.44.0",
190
+ "@typescript-eslint/parser": "^8.44.0",
191
+ "@vitejs/plugin-react": "^5.0.3",
192
192
  "concurrently": "^9.2.1",
193
193
  "cross-env": "^10.0.0",
194
194
  "eslint": "^9.35.0",
@@ -196,16 +196,16 @@
196
196
  "eslint-plugin-react": "^7.37.5",
197
197
  "eslint-plugin-react-hooks": "^5.2.0",
198
198
  "globals": "^16.4.0",
199
- "npm-check-updates": "^18.1.0",
199
+ "npm-check-updates": "^18.1.1",
200
200
  "prettier": "^3.6.2",
201
201
  "sass": "^1.92.1",
202
202
  "ts-node": "^10.9.2",
203
203
  "typescript": "^5.9.2",
204
- "vite": "^7.1.5",
204
+ "vite": "^7.1.6",
205
205
  "vitest": "^3.2.4"
206
206
  },
207
207
  "optionalDependencies": {
208
- "@rollup/rollup-darwin-arm64": "^4.50.1",
209
- "@rollup/rollup-win32-x64-msvc": "^4.50.1"
208
+ "@rollup/rollup-darwin-arm64": "^4.50.2",
209
+ "@rollup/rollup-win32-x64-msvc": "^4.50.2"
210
210
  }
211
211
  }
@@ -101,7 +101,18 @@ export const AIAssistWidget = ({
101
101
  : currentAIStreamId,
102
102
  );
103
103
  }
104
- }, [activeFileId, paneId, aiStreamId]);
104
+ }, [
105
+ activeFileId,
106
+ paneId,
107
+ aiStreamId,
108
+ aiAssistEndpoint,
109
+ aiAssistOptions,
110
+ editorCache,
111
+ runCodeRef,
112
+ runPrettierRef,
113
+ shareDBDoc,
114
+ tabList,
115
+ ]);
105
116
 
106
117
  const showWidget = enableStopGeneration
107
118
  ? true
@@ -138,7 +138,7 @@ export const useShareDB = ({
138
138
  // shareDBDoc.destroy();
139
139
  // docPresence.destroy();
140
140
  };
141
- }, []);
141
+ }, [connection]);
142
142
 
143
143
  // True for "Saving..."
144
144
  // False for "Saved"
@@ -134,15 +134,16 @@ export const CodeEditor = ({
134
134
  }, [
135
135
  activePane,
136
136
  shareDBDoc,
137
- filesPath,
138
137
  localPresence,
139
138
  docPresence,
140
139
  theme,
141
140
  onInteract,
142
141
  editorCache,
143
- usernameRef,
142
+ customInteractRules,
144
143
  aiCopilotEndpoint,
145
144
  esLintSource,
145
+ setIsAIChatOpen,
146
+ handleSendMessage,
146
147
  ]);
147
148
 
148
149
  // Every time the active file switches from one file to another,
@@ -155,10 +156,10 @@ export const CodeEditor = ({
155
156
  if (!editorCacheValue) return; // Guard against null editorCacheValue
156
157
  // if (openSplitPane) return;
157
158
 
159
+ const codeEditorEl = codeEditorRef.current;
160
+
158
161
  // Add the editor and apply the prior scroll position to the DOM.
159
- codeEditorRef.current.appendChild(
160
- editorCacheValue.editor.dom,
161
- );
162
+ codeEditorEl.appendChild(editorCacheValue.editor.dom);
162
163
  editorCacheValue.editor.scrollDOM.scrollTo({
163
164
  top: editorCacheValue.scrollPosition ?? 0,
164
165
  });
@@ -168,18 +169,16 @@ export const CodeEditor = ({
168
169
  // This happens every time `activeFileId` changes.
169
170
  if (
170
171
  editorCacheValue &&
171
- codeEditorRef.current?.contains(
172
- editorCacheValue.editor.dom,
173
- )
172
+ codeEditorEl?.contains(editorCacheValue.editor.dom)
174
173
  ) {
175
174
  editorCacheValue.scrollPosition =
176
175
  editorCacheValue.editor.scrollDOM.scrollTop;
177
- codeEditorRef.current.removeChild(
176
+ codeEditorEl.removeChild(
178
177
  editorCacheValue.editor.dom,
179
178
  );
180
179
  }
181
180
  };
182
- }, [shareDBDoc, editorCacheValue]);
181
+ }, [codeEditorRef, content, editorCacheValue]);
183
182
 
184
183
  // Focus the editor
185
184
  useEffect(() => {
@@ -170,7 +170,6 @@ export type VZCodeContextValue = {
170
170
  setVoiceChatModalOpen: (state: boolean) => void;
171
171
  aiChatMessage: string;
172
172
  setAIChatMessage: (message: string) => void;
173
- isLoading: boolean;
174
173
  currentChatId: string;
175
174
  selectedChatId: string | null;
176
175
  setSelectedChatId: (chatId: string | null) => void;
@@ -207,6 +206,9 @@ export type VZCodeContextValue = {
207
206
  options?: Record<string, string>,
208
207
  ) => void;
209
208
  }) => React.ReactNode;
209
+
210
+ // Feature flags
211
+ enableMinimalEditFlow: boolean;
210
212
  };
211
213
 
212
214
  export interface VZCodeProviderProps {
@@ -27,6 +27,7 @@ import {
27
27
  VZCodeContextValue,
28
28
  VZCodeProviderProps,
29
29
  } from './types';
30
+ import { enableMinimalEditFlow } from '../featureFlags';
30
31
 
31
32
  export const useVZCodeState = ({
32
33
  content,
@@ -280,7 +281,6 @@ export const useVZCodeState = ({
280
281
  // Use selectedChatId if available, otherwise use currentChatId for backward compatibility
281
282
  const activeChatId = selectedChatId || currentChatId;
282
283
  const currentChat = content?.chats?.[activeChatId];
283
- const isLoading = currentChat?.aiStatus === 'generating';
284
284
 
285
285
  // Message history for up/down arrow navigation - using ShareDB document data
286
286
  const messageHistory = useMemo(() => {
@@ -371,8 +371,7 @@ export const useVZCodeState = ({
371
371
  if (
372
372
  !messageContent ||
373
373
  typeof messageContent !== 'string' ||
374
- !messageContent.trim() ||
375
- isLoading
374
+ !messageContent.trim()
376
375
  ) {
377
376
  return;
378
377
  }
@@ -473,12 +472,13 @@ export const useVZCodeState = ({
473
472
  },
474
473
  [
475
474
  aiChatMessage,
476
- isLoading,
477
475
  aiChatEndpoint,
478
476
  aiChatOptions,
479
477
  activeChatId,
480
478
  aiChatMode,
481
479
  autoForkAndRetryAI,
480
+ DEBUG,
481
+ setAIErrorMessage,
482
482
  ],
483
483
  );
484
484
 
@@ -679,7 +679,6 @@ export const useVZCodeState = ({
679
679
 
680
680
  aiChatMessage,
681
681
  setAIChatMessage,
682
- isLoading,
683
682
  currentChatId,
684
683
  selectedChatId,
685
684
  setSelectedChatId,
@@ -699,5 +698,8 @@ export const useVZCodeState = ({
699
698
 
700
699
  // Additional widgets that can be rendered in AI chat messages
701
700
  additionalWidgets,
701
+
702
+ // Feature flags
703
+ enableMinimalEditFlow,
702
704
  };
703
705
  };
@@ -42,7 +42,7 @@ export const VZRight = () => {
42
42
  content
43
43
  ? vizFilesToFileCollection(content.files)
44
44
  : null,
45
- [content?.files],
45
+ [content],
46
46
  );
47
47
 
48
48
  const isInteracting = content?.isInteracting || false;
@@ -101,7 +101,15 @@ export const VZRight = () => {
101
101
  // runtime.cleanup();
102
102
  // worker.terminate();
103
103
  // };
104
- }, [files, isInteracting, runIdChanged, hardRerun]);
104
+ }, [
105
+ files,
106
+ isInteracting,
107
+ runIdChanged,
108
+ hardRerun,
109
+ clearRuntimeError,
110
+ handleRuntimeError,
111
+ iframeRef,
112
+ ]);
105
113
 
106
114
  return (
107
115
  <div className="right">
@@ -18,7 +18,6 @@ interface ChatInputProps {
18
18
  aiChatMessage: string;
19
19
  setAIChatMessage: (message: string) => void;
20
20
  onSendMessage: () => void;
21
- isLoading: boolean;
22
21
  focused: boolean;
23
22
  aiChatMode: 'ask' | 'edit';
24
23
  setAIChatMode: (mode: 'ask' | 'edit') => void;
@@ -31,7 +30,6 @@ const ChatInputComponent = ({
31
30
  aiChatMessage,
32
31
  setAIChatMessage,
33
32
  onSendMessage,
34
- isLoading,
35
33
  focused,
36
34
  aiChatMode,
37
35
  setAIChatMode,
@@ -141,7 +139,6 @@ const ChatInputComponent = ({
141
139
  value="ask"
142
140
  checked={aiChatMode === 'ask'}
143
141
  onChange={() => setAIChatMode('ask')}
144
- disabled={isLoading}
145
142
  >
146
143
  💬 Ask
147
144
  </ToggleButton>
@@ -157,7 +154,6 @@ const ChatInputComponent = ({
157
154
  value="edit"
158
155
  checked={aiChatMode === 'edit'}
159
156
  onChange={() => setAIChatMode('edit')}
160
- disabled={isLoading}
161
157
  >
162
158
  ✏️ Edit
163
159
  </ToggleButton>
@@ -183,7 +179,6 @@ const ChatInputComponent = ({
183
179
  : 'Ask me anything about your code...'
184
180
  }
185
181
  spellCheck="false"
186
- disabled={isLoading}
187
182
  aria-label="Chat message input"
188
183
  />
189
184
  <div className="ai-chat-input-footer">
@@ -197,7 +192,6 @@ const ChatInputComponent = ({
197
192
  }
198
193
  size="sm"
199
194
  onClick={toggleSpeechRecognition}
200
- disabled={isLoading}
201
195
  aria-label={
202
196
  isSpeaking
203
197
  ? 'Stop voice typing'
@@ -226,7 +220,7 @@ const ChatInputComponent = ({
226
220
  : 'outline-secondary'
227
221
  }
228
222
  onClick={handleSendClick}
229
- disabled={!aiChatMessage.trim() || isLoading}
223
+ disabled={!aiChatMessage.trim()}
230
224
  className="ai-chat-send-button"
231
225
  aria-label="Send message"
232
226
  title="Send message (Enter)"
@@ -100,6 +100,7 @@
100
100
 
101
101
  // Fix file header styling
102
102
  .d2h-file-header {
103
+ display: none !important;
103
104
  background-color: var(
104
105
  --d2h-file-header-bg-color
105
106
  ) !important;
@@ -2,6 +2,8 @@ import React, {
2
2
  useContext,
3
3
  useEffect,
4
4
  useRef,
5
+ forwardRef,
6
+ useImperativeHandle,
5
7
  } from 'react';
6
8
  import {
7
9
  UnifiedFilesDiff,
@@ -12,15 +14,29 @@ import * as Diff2Html from 'diff2html';
12
14
  import 'diff2html/bundles/css/diff2html.min.css';
13
15
  import './DiffView.scss';
14
16
  import { VZCodeContext } from '../../VZCodeContext';
15
- import { VizFiles } from '@vizhub/viz-types';
17
+ import {
18
+ scrollToFirstDiff,
19
+ getHeaderOffset,
20
+ announceDiffSummary,
21
+ } from '../../utils/scrollUtils';
22
+ import { getFileId } from '@vizhub/viz-utils';
16
23
 
17
24
  interface DiffViewProps {
18
25
  diffData: UnifiedFilesDiff;
19
26
  }
20
27
 
21
- export const DiffView: React.FC<DiffViewProps> = ({
22
- diffData,
23
- }) => {
28
+ // Methods that can be called on DiffView from parent components
29
+ export interface DiffViewRef {
30
+ scrollToFirstHunk: () => void;
31
+ focusDiffContainer: () => void;
32
+ announceSummary: () => void;
33
+ getFirstHunkElement: () => HTMLElement | null;
34
+ }
35
+
36
+ export const DiffView = forwardRef<
37
+ DiffViewRef,
38
+ DiffViewProps
39
+ >(({ diffData }, ref) => {
24
40
  const { content, openTab, setIsAIChatOpen } =
25
41
  useContext(VZCodeContext);
26
42
  const diffContainerRef = useRef<HTMLDivElement>(null);
@@ -48,6 +64,42 @@ export const DiffView: React.FC<DiffViewProps> = ({
48
64
  outputFormat: 'line-by-line',
49
65
  });
50
66
 
67
+ // Expose methods for parent components to control scrolling and focus
68
+ useImperativeHandle(
69
+ ref,
70
+ () => ({
71
+ scrollToFirstHunk: () => {
72
+ if (diffContainerRef.current) {
73
+ const headerOffset = getHeaderOffset();
74
+ scrollToFirstDiff(
75
+ diffContainerRef.current,
76
+ headerOffset,
77
+ );
78
+ }
79
+ },
80
+ focusDiffContainer: () => {
81
+ if (diffContainerRef.current) {
82
+ diffContainerRef.current.tabIndex = -1;
83
+ diffContainerRef.current.focus();
84
+ }
85
+ },
86
+ announceSummary: () => {
87
+ if (diffContainerRef.current) {
88
+ announceDiffSummary(diffContainerRef.current);
89
+ }
90
+ },
91
+ getFirstHunkElement: () => {
92
+ if (diffContainerRef.current) {
93
+ return diffContainerRef.current.querySelector(
94
+ '.d2h-diff-tbody tr',
95
+ );
96
+ }
97
+ return null;
98
+ },
99
+ }),
100
+ [],
101
+ );
102
+
51
103
  // Add click handlers to file names after HTML is rendered
52
104
  useEffect(() => {
53
105
  if (!diffContainerRef.current) return;
@@ -63,29 +115,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
63
115
  const fileName = target.textContent?.trim();
64
116
 
65
117
  console.log('Clicked file name:', fileName);
66
- if (fileName) {
67
- const files: VizFiles = content?.files;
68
- if (files) {
69
- // export type VizFiles = {
70
- // [fileId: VizFileId]: VizFile;
71
- // };
72
- // export type VizFileId = string;
73
- // export type VizFile = {
74
- // name: string;
75
- // text: string;
76
- // };
77
-
78
- // TODO get fileId from content.files
79
- const fileId = Object.entries(files).find(
80
- ([_id, file]) => file.name === fileName,
81
- )?.[0];
82
- console.log('Mapped file ID:', fileId);
83
-
84
- if (fileId) {
85
- // Open the file tab and switch to files view
86
- openTab({ fileId, isTransient: false });
87
- setIsAIChatOpen(false);
88
- }
118
+ if (fileName && content) {
119
+ const fileId = getFileId(content, fileName);
120
+
121
+ if (fileId) {
122
+ // Open the file tab and switch to files view
123
+ openTab({ fileId, isTransient: false });
124
+ setIsAIChatOpen(false);
89
125
  }
90
126
  }
91
127
  }
@@ -125,7 +161,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
125
161
 
126
162
  return (
127
163
  <div className="diff-view">
128
- <div className="diff-summary">
164
+ <div className="diff-summary" id="diff-summary">
129
165
  <div className="diff-stats">
130
166
  <span className="files-changed">
131
167
  {unifiedDiffs.length} file
@@ -147,8 +183,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
147
183
  <div
148
184
  className="diff-files"
149
185
  ref={diffContainerRef}
186
+ tabIndex={-1}
187
+ role="region"
188
+ aria-label="Code diff content"
189
+ aria-describedby="diff-summary"
150
190
  dangerouslySetInnerHTML={{ __html: diffHtml }}
151
191
  />
152
192
  </div>
153
193
  );
154
- };
194
+ });
195
+
196
+ // Add display name for debugging
197
+ DiffView.displayName = 'DiffView';
@@ -0,0 +1,81 @@
1
+ import { Spinner } from '../../AIAssist/Spinner';
2
+
3
+ interface AIEditingStatusIndicatorProps {
4
+ status: string;
5
+ fileName?: string;
6
+ }
7
+
8
+ export const AIEditingStatusIndicator: React.FC<
9
+ AIEditingStatusIndicatorProps
10
+ > = ({ status, fileName }) => {
11
+ const getStatusDisplay = (status: string) => {
12
+ switch (status) {
13
+ case 'Analyzing request...':
14
+ return (
15
+ <>
16
+ 🔍 <span>Analyzing request...</span>
17
+ </>
18
+ );
19
+ case 'Formulating a plan...':
20
+ return (
21
+ <>
22
+ 💭 <span>Formulating a plan...</span>
23
+ </>
24
+ );
25
+ case 'Describing changes...':
26
+ return (
27
+ <>
28
+ 📝 <span>Describing changes...</span>
29
+ </>
30
+ );
31
+ case 'Thinking...':
32
+ return (
33
+ <>
34
+ 🤔 <span>Thinking...</span>
35
+ </>
36
+ );
37
+ case 'Done':
38
+ return (
39
+ <>
40
+ ✅ <span>Done</span>
41
+ </>
42
+ );
43
+ default:
44
+ // Handle file editing status (e.g., "Editing filename.js...")
45
+ if (status.startsWith('Editing ') && fileName) {
46
+ return (
47
+ <>
48
+ ✏️{' '}
49
+ <span>
50
+ Editing <code>{fileName}</code>...
51
+ </span>
52
+ </>
53
+ );
54
+ } else if (status.startsWith('Editing ')) {
55
+ return (
56
+ <>
57
+ ✏️ <span>{status}</span>
58
+ </>
59
+ );
60
+ }
61
+ return <span>{status}</span>;
62
+ }
63
+ };
64
+
65
+ const showSpinner = status !== 'Done';
66
+
67
+ return (
68
+ <div className="file-editing-indicator">
69
+ <div className="file-editing-header">
70
+ {showSpinner && (
71
+ <div className="file-editing-icon">
72
+ <Spinner height={16} fadeIn={false} />
73
+ </div>
74
+ )}
75
+ <div className="file-editing-text">
76
+ {getStatusDisplay(status)}
77
+ </div>
78
+ </div>
79
+ </div>
80
+ );
81
+ };
@@ -0,0 +1,86 @@
1
+ import React, { useMemo } from 'react';
2
+ import * as Diff2Html from 'diff2html';
3
+ import 'diff2html/bundles/css/diff2html.min.css';
4
+ import './DiffView.scss';
5
+ import { generateFileUnifiedDiff } from '../../../utils/fileDiff';
6
+
7
+ interface IndividualFileDiffProps {
8
+ fileName: string;
9
+ beforeContent: string;
10
+ afterContent: string;
11
+ }
12
+
13
+ export const IndividualFileDiff: React.FC<
14
+ IndividualFileDiffProps
15
+ > = ({ fileName, beforeContent, afterContent }) => {
16
+ // Generate unified diff for this single file
17
+ const unifiedDiff = useMemo(() => {
18
+ return generateFileUnifiedDiff(
19
+ 'temp-id',
20
+ fileName,
21
+ beforeContent,
22
+ afterContent,
23
+ );
24
+ }, [fileName, beforeContent, afterContent]);
25
+
26
+ // Convert to HTML using diff2html
27
+ const diffHtml = useMemo(() => {
28
+ if (!unifiedDiff) return '';
29
+
30
+ return Diff2Html.html(unifiedDiff, {
31
+ drawFileList: false,
32
+ matching: 'words',
33
+ diffStyle: 'word',
34
+ outputFormat: 'line-by-line',
35
+ });
36
+ }, [unifiedDiff]);
37
+
38
+ // Calculate simple stats
39
+ const stats = useMemo(() => {
40
+ const lines = unifiedDiff.split('\n');
41
+ let additions = 0;
42
+ let deletions = 0;
43
+
44
+ lines.forEach((line) => {
45
+ if (line.startsWith('+') && !line.startsWith('+++')) {
46
+ additions++;
47
+ } else if (
48
+ line.startsWith('-') &&
49
+ !line.startsWith('---')
50
+ ) {
51
+ deletions++;
52
+ }
53
+ });
54
+
55
+ return { additions, deletions };
56
+ }, [unifiedDiff]);
57
+
58
+ if (!unifiedDiff) {
59
+ return null;
60
+ }
61
+
62
+ return (
63
+ <div className="diff-view" data-file={fileName}>
64
+ <div className="diff-summary">
65
+ <div className="diff-stats">
66
+ <span className="files-changed">{fileName}</span>
67
+ {stats.additions > 0 && (
68
+ <span className="additions">
69
+ +{stats.additions}
70
+ </span>
71
+ )}
72
+ {stats.deletions > 0 && (
73
+ <span className="deletions">
74
+ -{stats.deletions}
75
+ </span>
76
+ )}
77
+ </div>
78
+ </div>
79
+
80
+ <div
81
+ className="diff-files"
82
+ dangerouslySetInnerHTML={{ __html: diffHtml }}
83
+ />
84
+ </div>
85
+ );
86
+ };
@@ -0,0 +1,64 @@
1
+ import React, { useCallback } from 'react';
2
+
3
+ /**
4
+ * Props for the JumpToLatestButton component
5
+ */
6
+ interface JumpToLatestButtonProps {
7
+ /** Whether the button is visible */
8
+ visible: boolean;
9
+ /** Callback when button is clicked */
10
+ onClick: () => void;
11
+ /** Additional CSS class name */
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Circular "down arrow" button that floats at the bottom center of scroll area
17
+ * Appears when auto-scroll is disabled and user is not at bottom
18
+ */
19
+ export const JumpToLatestButton: React.FC<
20
+ JumpToLatestButtonProps
21
+ > = ({ visible, onClick, className = '' }) => {
22
+ /**
23
+ * Handle button click
24
+ */
25
+ const handleClick = useCallback(() => {
26
+ onClick();
27
+ }, [onClick]);
28
+
29
+ /**
30
+ * Handle keyboard events for accessibility
31
+ */
32
+ const handleKeyDown = useCallback(
33
+ (event: React.KeyboardEvent) => {
34
+ if (event.key === 'Enter' || event.key === ' ') {
35
+ event.preventDefault();
36
+ onClick();
37
+ }
38
+ },
39
+ [onClick],
40
+ );
41
+
42
+ if (!visible) {
43
+ return null;
44
+ }
45
+
46
+ return (
47
+ <button
48
+ className={`jump-to-latest-button ${className}`.trim()}
49
+ onClick={handleClick}
50
+ onKeyDown={handleKeyDown}
51
+ aria-label="Jump to latest"
52
+ tabIndex={0}
53
+ type="button"
54
+ >
55
+ {/* Down arrow icon using Unicode or can be replaced with SVG */}
56
+ <span
57
+ className="jump-to-latest-icon"
58
+ aria-hidden="true"
59
+ >
60
+
61
+ </span>
62
+ </button>
63
+ );
64
+ };