vzcode 1.45.0 → 1.46.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 (33) hide show
  1. package/dist/assets/{index-C1lTrzOZ.js → index-CC0CUUDi.js} +113 -113
  2. package/dist/assets/{index-DeeOcIzw.css → index-CQyHWTWE.css} +1 -1
  3. package/dist/index.html +2 -2
  4. package/package.json +7 -3
  5. package/src/client/CodeEditor/index.tsx +7 -4
  6. package/src/client/RunCodeWidget/index.tsx +18 -15
  7. package/src/client/SplitPaneResizeContext.tsx +26 -108
  8. package/src/client/VZCodeContext.tsx +8 -1
  9. package/src/client/VZRight.tsx +32 -4
  10. package/src/client/VZSidebar/AIChat/ChatInput.tsx +72 -2
  11. package/src/client/VZSidebar/AIChat/DiffView.scss +29 -0
  12. package/src/client/VZSidebar/AIChat/DiffView.tsx +62 -1
  13. package/src/client/VZSidebar/AIChat/Message.tsx +14 -1
  14. package/src/client/VZSidebar/AIChat/MessageList.tsx +134 -13
  15. package/src/client/VZSidebar/AIChat/ThinkingScratchpad.tsx +37 -0
  16. package/src/client/VZSidebar/AIChat/index.tsx +148 -12
  17. package/src/client/VZSidebar/AIChat/styles.scss +223 -1
  18. package/src/client/VZSidebar/aiCopyPaste.ts +6 -1
  19. package/src/client/VZSidebar/index.tsx +10 -2
  20. package/src/client/bootstrap.ts +11 -1
  21. package/src/client/useActions.ts +12 -0
  22. package/src/client/usePrettier/index.ts +1 -3
  23. package/src/client/vzReducer/aiChatReducer.ts +13 -0
  24. package/src/client/vzReducer/createInitialState.ts +1 -0
  25. package/src/client/vzReducer/index.ts +9 -0
  26. package/src/client/vzReducer/searchReducer.test.ts +44 -2
  27. package/src/runCode.ts +5 -17
  28. package/src/server/aiChatHandler/aiEditing.ts +37 -0
  29. package/src/server/aiChatHandler/chatOperations.ts +38 -1
  30. package/src/server/aiChatHandler/index.ts +20 -9
  31. package/src/server/aiChatHandler/llmStreaming.ts +101 -19
  32. package/src/server/generateAIResponse.ts +4 -0
  33. package/src/server/index.ts +52 -0
package/dist/index.html CHANGED
@@ -20,8 +20,8 @@
20
20
  href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"
21
21
  rel="stylesheet"
22
22
  />
23
- <script type="module" crossorigin src="/assets/index-C1lTrzOZ.js"></script>
24
- <link rel="stylesheet" crossorigin href="/assets/index-DeeOcIzw.css">
23
+ <script type="module" crossorigin src="/assets/index-CC0CUUDi.js"></script>
24
+ <link rel="stylesheet" crossorigin href="/assets/index-CQyHWTWE.css">
25
25
  </head>
26
26
  <body>
27
27
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vzcode",
3
- "version": "1.45.0",
3
+ "version": "1.46.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -18,6 +18,9 @@
18
18
  "build": "vite build",
19
19
  "build-release": "vite build --mode release",
20
20
  "preview": "concurrently \"npm run test-interactive\" \"vite preview\"",
21
+ "tauri": "tauri",
22
+ "tauri:dev": "tauri dev",
23
+ "tauri:build": "tauri build",
21
24
  "prepublishOnly": "npm run build-release",
22
25
  "upgrade": "ncu -x react,react-dom,@types/react,@types/react-dom -u"
23
26
  },
@@ -135,8 +138,8 @@
135
138
  "@uiw/codemirror-themes": "^4.24.2",
136
139
  "@valtown/codemirror-ts": "^2.3.1",
137
140
  "@vizhub/runtime": "^4.3.0",
138
- "@vizhub/viz-types": "^0.3.0",
139
- "@vizhub/viz-utils": "^1.3.0",
141
+ "@vizhub/viz-types": "^0.4.0",
142
+ "@vizhub/viz-utils": "^1.4.0",
140
143
  "body-parser": "^2.2.0",
141
144
  "codemirror": "^6.0.2",
142
145
  "codemirror-copilot": "^0.0.7",
@@ -173,6 +176,7 @@
173
176
  },
174
177
  "devDependencies": {
175
178
  "@eslint/js": "^9.32.0",
179
+ "@tauri-apps/cli": "^2.7.1",
176
180
  "@types/react": "^18",
177
181
  "@types/react-dom": "^18",
178
182
  "@vitejs/plugin-react": "^4.7.0",
@@ -43,7 +43,6 @@ export const CodeEditor = ({
43
43
  theme,
44
44
  codeEditorRef,
45
45
  enableAutoFollow,
46
- openTab,
47
46
  setIsAIChatOpen,
48
47
  setAIChatMessage,
49
48
  } = useContext(VZCodeContext);
@@ -63,13 +62,17 @@ export const CodeEditor = ({
63
62
  clearTimeout(interactTimeoutRef.current);
64
63
  }
65
64
 
66
- // Set `isInteracting: undefined` after a delay.
65
+ // Set `isInteracting: undefined` after a delay,
66
+ // so that when the runId changes in the future,
67
+ // it will trigger a hard re-run, not a hot reload.
67
68
  interactTimeoutRef.current = setTimeout(() => {
68
69
  interactTimeoutRef.current = null;
69
70
 
70
- // This logic deletes the `isInteracting` property from the document.
71
+ // This logic deletes the `isInteracting` property from the document
71
72
  submitOperation(
72
- ({ isInteracting, ...newDocument }) => newDocument,
73
+ ({ isInteracting, ...newDocument }) => ({
74
+ ...newDocument,
75
+ }),
73
76
  );
74
77
  }, 800);
75
78
  }, [submitOperation]);
@@ -28,24 +28,27 @@ export const RunCodeWidget = ({
28
28
  useContext(VZCodeContext);
29
29
  const [isRunning, setIsRunning] = useState(false);
30
30
 
31
- const handleClick = useCallback(() => {
32
- setIsRunning(true); // Set the running state to true
31
+ const handleClick = useCallback(
32
+ (event?: React.MouseEvent) => {
33
+ setIsRunning(true); // Set the running state to true
33
34
 
34
- // Run Prettier
35
- const runPrettier = runPrettierRef.current;
36
- if (runPrettier !== null) {
37
- runPrettier();
38
- }
35
+ // Run Prettier
36
+ const runPrettier = runPrettierRef.current;
37
+ if (runPrettier !== null) {
38
+ runPrettier();
39
+ }
39
40
 
40
- // Run the code
41
- const runCode = runCodeRef.current;
42
- if (runCode !== null) {
43
- runCode();
44
- }
41
+ // Run the code
42
+ const runCode = runCodeRef.current;
43
+ if (runCode !== null) {
44
+ runCode();
45
+ }
45
46
 
46
- // Optional: reset the icon state after animation completes (e.g., 1 second)
47
- setTimeout(() => setIsRunning(false), 1000);
48
- }, [runCodeRef, runPrettierRef]);
47
+ // Optional: reset the icon state after animation completes (e.g., 1 second)
48
+ setTimeout(() => setIsRunning(false), 1000);
49
+ },
50
+ [runCodeRef, runPrettierRef],
51
+ );
49
52
 
50
53
  const handleSplitEditor = useCallback(() => {
51
54
  splitCurrentPane();
@@ -2,9 +2,8 @@
2
2
  import {
3
3
  createContext,
4
4
  useReducer,
5
- useEffect,
6
5
  useCallback,
7
- useLayoutEffect,
6
+ useEffect,
8
7
  } from 'react';
9
8
 
10
9
  export type Side = 'left' | 'right';
@@ -16,63 +15,22 @@ export type SplitPaneResizeContextValue = {
16
15
  isDraggingRight: boolean;
17
16
  isDraggingLeft: boolean;
18
17
  setIsDragging: (a: boolean, side: Side) => void;
18
+ setSidebarView: (isAIChatOpen: boolean) => void;
19
19
  };
20
20
 
21
- // Feature flag for developers to use when testing.
22
- const enableLocalStorage = true;
23
-
24
- // The amount of time to wait idle before writing to localStorage.
25
- // This is to avoid writing to localStorage on every resize event.
26
- // MS = milliseconds
27
- const localStorageWriteDebounceMS = 800;
28
-
29
- // Properties in LocalStorage
30
- const sidebarWidthProperty = 'vzCodeSidebarWidth';
31
- const codeEditorWidthProperty = 'vzCodeCodeEditorWidth';
32
-
33
- const initialSidebarWidthDefault = 250;
34
- let initialSidebarWidth: number =
35
- initialSidebarWidthDefault;
36
-
21
+ // Default widths for different sidebar views
22
+ const filesViewWidth = 300;
23
+ const aiChatViewWidth = 600;
37
24
  const initialCodeEditorWidthDefault = 800;
38
- let initialCodeEditorWidth: number =
39
- initialCodeEditorWidthDefault;
40
-
41
- // If no value is found in localStorage, we'll use the default.
42
- let needsInitialization = false;
43
-
44
- // If we're in the browser,
45
- if (typeof window !== 'undefined' && enableLocalStorage) {
46
- //check localStorage for a previously stored width.
47
- const sidebarWidthLocal: string | null =
48
- window.localStorage.getItem(sidebarWidthProperty);
49
-
50
- const codeEditorWidthLocal: string | null =
51
- window.localStorage.getItem(codeEditorWidthProperty);
52
-
53
- // If there is a previously stored width,
54
- if (sidebarWidthLocal !== null) {
55
- // use it as the initial width.
56
- initialSidebarWidth = +sidebarWidthLocal;
57
- needsInitialization = true;
58
- }
59
-
60
- if (codeEditorWidthLocal !== null) {
61
- // use it as the initial width.
62
- initialCodeEditorWidth = +codeEditorWidthLocal;
63
- needsInitialization = true;
64
- }
65
- } else {
66
- // If we're not in the browser, use the default initial width.
67
- }
68
25
 
69
26
  const initialValue: SplitPaneResizeContextValue = {
70
- codeEditorWidth: initialCodeEditorWidth,
71
- sidebarWidth: initialSidebarWidth,
27
+ codeEditorWidth: initialCodeEditorWidthDefault,
28
+ sidebarWidth: filesViewWidth, // Default to files view
72
29
  moveSplitPane: () => {},
73
30
  isDraggingRight: false,
74
31
  isDraggingLeft: false,
75
32
  setIsDragging: () => {},
33
+ setSidebarView: () => {},
76
34
  };
77
35
 
78
36
  export const SplitPaneResizeContext =
@@ -84,30 +42,13 @@ type SplitPaneState = {
84
42
  codeEditorWidth: number;
85
43
  isDraggingLeft: boolean;
86
44
  isDraggingRight: boolean;
87
-
88
- // False on first render
89
- isInitialized: boolean;
90
45
  };
91
46
 
92
47
  const initialState: SplitPaneState = {
93
- // sidebarWidth: initialSidebarWidth,
94
- // codeEditorWidth: initialCodeEditorWidth,
95
-
96
- // These need to match between SSR and the first
97
- // client-side render (hydration), to avoid
98
- // React hydration errors like this one:
99
- // * Warning: Prop `style` did not match.
100
- // * Server: "left:210px;width:20px"
101
- // * Client: "left:218px;width:20px"
102
-
103
- sidebarWidth: initialSidebarWidthDefault,
48
+ sidebarWidth: filesViewWidth,
104
49
  codeEditorWidth: initialCodeEditorWidthDefault,
105
50
  isDraggingLeft: false,
106
51
  isDraggingRight: false,
107
-
108
- // Solution for hydration errors: we set the value from localStorage
109
- // in a layoutEffect that happens after first render.
110
- isInitialized: false,
111
52
  };
112
53
 
113
54
  type SplitPaneAction =
@@ -122,7 +63,8 @@ type SplitPaneAction =
122
63
  isDragging: boolean;
123
64
  }
124
65
  | {
125
- type: 'initialize';
66
+ type: 'setSidebarView';
67
+ isAIChatOpen: boolean;
126
68
  };
127
69
 
128
70
  const splitPaneReducer = (
@@ -130,13 +72,6 @@ const splitPaneReducer = (
130
72
  action: SplitPaneAction,
131
73
  ) => {
132
74
  switch (action.type) {
133
- case 'initialize':
134
- return {
135
- ...state,
136
- sidebarWidth: initialSidebarWidth,
137
- codeEditorWidth: initialCodeEditorWidth,
138
- isInitialized: true,
139
- };
140
75
  case 'move':
141
76
  return action.side === 'left'
142
77
  ? {
@@ -160,6 +95,13 @@ const splitPaneReducer = (
160
95
  ...state,
161
96
  isDraggingRight: action.isDragging,
162
97
  };
98
+ case 'setSidebarView':
99
+ return {
100
+ ...state,
101
+ sidebarWidth: action.isAIChatOpen
102
+ ? aiChatViewWidth
103
+ : filesViewWidth,
104
+ };
163
105
  default:
164
106
  return state;
165
107
  }
@@ -185,45 +127,20 @@ export const SplitPaneResizeProvider = ({ children }) => {
185
127
  [dispatch],
186
128
  );
187
129
 
130
+ const setSidebarView = useCallback(
131
+ (isAIChatOpen: boolean) => {
132
+ dispatch({ type: 'setSidebarView', isAIChatOpen });
133
+ },
134
+ [dispatch],
135
+ );
136
+
188
137
  const {
189
138
  sidebarWidth,
190
139
  codeEditorWidth,
191
140
  isDraggingLeft,
192
141
  isDraggingRight,
193
- isInitialized,
194
142
  } = state;
195
143
 
196
- // Logic around initializing from localStorage.
197
- // Only run this on the client.
198
- if (typeof window !== 'undefined') {
199
- useLayoutEffect(() => {
200
- if (needsInitialization && !isInitialized) {
201
- dispatch({ type: 'initialize' });
202
- }
203
- }, [isInitialized]);
204
- }
205
-
206
- // Logic around storing the values in localStorage.
207
- useEffect(() => {
208
- if (sidebarWidth !== initialSidebarWidth) {
209
- setTimeout(() => {
210
- window.localStorage.setItem(
211
- sidebarWidthProperty,
212
- '' + sidebarWidth,
213
- );
214
- }, localStorageWriteDebounceMS);
215
- }
216
-
217
- if (codeEditorWidth !== initialCodeEditorWidth) {
218
- setTimeout(() => {
219
- window.localStorage.setItem(
220
- codeEditorWidthProperty,
221
- '' + codeEditorWidth,
222
- );
223
- }, localStorageWriteDebounceMS);
224
- }
225
- }, [codeEditorWidth, sidebarWidth]);
226
-
227
144
  const value: SplitPaneResizeContextValue = {
228
145
  codeEditorWidth,
229
146
  sidebarWidth,
@@ -231,6 +148,7 @@ export const SplitPaneResizeProvider = ({ children }) => {
231
148
  isDraggingLeft,
232
149
  isDraggingRight,
233
150
  setIsDragging,
151
+ setSidebarView,
234
152
  };
235
153
 
236
154
  return (
@@ -126,6 +126,8 @@ export type VZCodeContextValue = {
126
126
  setIsAIChatOpen: (isAIChatOpen: boolean) => void;
127
127
  aiChatFocused: boolean;
128
128
  toggleAIChatFocused: () => void;
129
+ aiChatMode: 'ask' | 'edit';
130
+ setAIChatMode: (mode: 'ask' | 'edit') => void;
129
131
  aiChatEndpoint?: string;
130
132
  aiChatOptions?: { [key: string]: any };
131
133
  setSearchResults: (files: ShareDBDoc<VizContent>) => void;
@@ -158,7 +160,9 @@ export type VZCodeContextValue = {
158
160
  runPrettierRef: React.MutableRefObject<
159
161
  null | (() => void)
160
162
  >;
161
- runCodeRef: React.MutableRefObject<null | (() => void)>;
163
+ runCodeRef: React.MutableRefObject<
164
+ null | ((hardRerun?: boolean) => void)
165
+ >;
162
166
 
163
167
  sidebarRef: React.RefObject<HTMLDivElement>;
164
168
 
@@ -317,6 +321,7 @@ export const VZCodeProvider = ({
317
321
  toggleSearchFocused,
318
322
  setIsAIChatOpen,
319
323
  toggleAIChatFocused,
324
+ setAIChatMode,
320
325
  setIsSettingsOpen,
321
326
  setIsDocOpen,
322
327
  closeSettings,
@@ -489,6 +494,8 @@ export const VZCodeProvider = ({
489
494
  setIsAIChatOpen,
490
495
  aiChatFocused,
491
496
  toggleAIChatFocused,
497
+ aiChatMode: state.aiChatMode,
498
+ setAIChatMode,
492
499
  aiChatEndpoint,
493
500
  aiChatOptions,
494
501
 
@@ -11,6 +11,14 @@ import {
11
11
  import BuildWorker from './buildWorker?worker';
12
12
  import { VZCodeContext } from './VZCodeContext';
13
13
  import { vizFilesToFileCollection } from '@vizhub/viz-utils';
14
+ import { VizContent } from '@vizhub/viz-types';
15
+
16
+ // Extend VizContent type to include hardRerun property
17
+ type ExtendedVizContent = VizContent & {
18
+ // TODO remove this, use the runId property from VizContent instead.
19
+ // If `VizContent.runId` changes, it will trigger a hard rerun.
20
+ hardRerun?: boolean;
21
+ };
14
22
 
15
23
  const enableIframe = true;
16
24
 
@@ -18,6 +26,9 @@ export const VZRight = () => {
18
26
  const iframeRef = useRef<HTMLIFrameElement>(null);
19
27
  const runtimeRef = useRef<VizHubRuntime | null>(null);
20
28
  const isFirstRunRef = useRef(true);
29
+ const lastRunIdRef = useRef<string | undefined>(
30
+ undefined,
31
+ );
21
32
 
22
33
  // Get access to the current files.
23
34
  const { content, handleRuntimeError, clearRuntimeError } =
@@ -32,6 +43,15 @@ export const VZRight = () => {
32
43
  );
33
44
 
34
45
  const isInteracting = content?.isInteracting || false;
46
+ const runId = content?.runId;
47
+ const hardRerun =
48
+ (content as ExtendedVizContent)?.hardRerun || false;
49
+
50
+ // Check if runId has changed
51
+ const runIdChanged = runId !== lastRunIdRef.current;
52
+ if (runIdChanged) {
53
+ lastRunIdRef.current = runId;
54
+ }
35
55
 
36
56
  useEffect(() => {
37
57
  if (!files) return;
@@ -51,14 +71,22 @@ export const VZRight = () => {
51
71
  });
52
72
  }
53
73
 
54
- // Run code in the iframe
55
- if (isFirstRunRef.current || isInteracting) {
74
+ // Run code in the iframe when:
75
+ // 1. First run
76
+ // 2. User is interacting with widgets
77
+ // 3. runId changed (indicating AI finished or other trigger)
78
+ if (
79
+ isFirstRunRef.current ||
80
+ isInteracting ||
81
+ runIdChanged
82
+ ) {
56
83
  // Clear runtime errors when new code runs
57
84
  clearRuntimeError();
58
85
 
59
86
  runtimeRef.current?.run({
60
87
  files,
61
- enableHotReloading: !isFirstRunRef.current,
88
+ // Enable hot reloading when interacting, disable when runId changed without interaction
89
+ enableHotReloading: isInteracting,
62
90
  enableSourcemap: true,
63
91
  vizId: 'example-viz',
64
92
  });
@@ -70,7 +98,7 @@ export const VZRight = () => {
70
98
  // runtime.cleanup();
71
99
  // worker.terminate();
72
100
  // };
73
- }, [files, isInteracting]);
101
+ }, [files, isInteracting, runIdChanged, hardRerun]);
74
102
 
75
103
  return (
76
104
  <div className="right">
@@ -4,7 +4,12 @@ import {
4
4
  useCallback,
5
5
  memo,
6
6
  } from 'react';
7
- import { Form, Button } from '../../bootstrap';
7
+ import {
8
+ Form,
9
+ Button,
10
+ ButtonGroup,
11
+ ToggleButton,
12
+ } from '../../bootstrap';
8
13
 
9
14
  interface ChatInputProps {
10
15
  aiChatMessage: string;
@@ -12,6 +17,8 @@ interface ChatInputProps {
12
17
  onSendMessage: () => void;
13
18
  isLoading: boolean;
14
19
  focused: boolean;
20
+ aiChatMode: 'ask' | 'edit';
21
+ setAIChatMode: (mode: 'ask' | 'edit') => void;
15
22
  }
16
23
 
17
24
  const ChatInputComponent = ({
@@ -20,6 +27,8 @@ const ChatInputComponent = ({
20
27
  onSendMessage,
21
28
  isLoading,
22
29
  focused,
30
+ aiChatMode,
31
+ setAIChatMode,
23
32
  }: ChatInputProps) => {
24
33
  const inputRef = useRef<HTMLTextAreaElement>(null);
25
34
 
@@ -49,6 +58,50 @@ const ChatInputComponent = ({
49
58
 
50
59
  return (
51
60
  <div className="ai-chat-input-container">
61
+ <div
62
+ className="ai-chat-mode-toggle"
63
+ style={{ marginBottom: '8px' }}
64
+ >
65
+ <ButtonGroup size="sm">
66
+ <ToggleButton
67
+ id="ai-chat-mode-ask"
68
+ type="radio"
69
+ variant={
70
+ aiChatMode === 'ask'
71
+ ? 'primary'
72
+ : 'outline-primary'
73
+ }
74
+ name="ai-chat-mode"
75
+ value="ask"
76
+ checked={aiChatMode === 'ask'}
77
+ onChange={() => setAIChatMode('ask')}
78
+ disabled={isLoading}
79
+ >
80
+ 💬 Ask
81
+ </ToggleButton>
82
+ <ToggleButton
83
+ id="ai-chat-mode-edit"
84
+ type="radio"
85
+ variant={
86
+ aiChatMode === 'edit'
87
+ ? 'primary'
88
+ : 'outline-primary'
89
+ }
90
+ name="ai-chat-mode"
91
+ value="edit"
92
+ checked={aiChatMode === 'edit'}
93
+ onChange={() => setAIChatMode('edit')}
94
+ disabled={isLoading}
95
+ >
96
+ ✏️ Edit
97
+ </ToggleButton>
98
+ </ButtonGroup>
99
+ <div className="ai-chat-mode-description">
100
+ {aiChatMode === 'ask'
101
+ ? 'Ask questions without editing files'
102
+ : 'Get answers and code edits'}
103
+ </div>
104
+ </div>
52
105
  <Form.Group className="ai-chat-input-group">
53
106
  <Form.Control
54
107
  as="textarea"
@@ -57,15 +110,32 @@ const ChatInputComponent = ({
57
110
  onChange={handleChange}
58
111
  onKeyDown={handleKeyDown}
59
112
  ref={inputRef}
60
- placeholder="Describe what you'd like me to change in your code..."
113
+ placeholder={
114
+ aiChatMode === 'edit'
115
+ ? 'Ask me to make code changes...'
116
+ : 'Ask me anything about your code...'
117
+ }
61
118
  spellCheck="false"
62
119
  disabled={isLoading}
120
+ aria-label="Chat message input"
63
121
  />
122
+ {aiChatMessage && (
123
+ <div className="ai-chat-input-info">
124
+ <span className="ai-chat-hint">
125
+ Shift+Enter for new line
126
+ </span>
127
+ <span className="ai-chat-hint">
128
+ Press Enter to send
129
+ </span>
130
+ </div>
131
+ )}
64
132
  <Button
65
133
  variant="primary"
66
134
  onClick={onSendMessage}
67
135
  disabled={!aiChatMessage.trim() || isLoading}
68
136
  className="ai-chat-send-button"
137
+ aria-label="Send message"
138
+ title="Send message (Enter)"
69
139
  >
70
140
  Send
71
141
  </Button>
@@ -3,6 +3,9 @@
3
3
 
4
4
  .diff-summary {
5
5
  margin-bottom: 8px;
6
+ display: flex;
7
+ justify-content: space-between;
8
+ align-items: center;
6
9
 
7
10
  .diff-stats {
8
11
  display: flex;
@@ -28,6 +31,32 @@
28
31
  }
29
32
  }
30
33
 
34
+ .undo-button-container {
35
+ display: flex;
36
+ justify-content: flex-end;
37
+ margin-top: 16px;
38
+ }
39
+
40
+ .undo-button {
41
+ background-color: #dc3545;
42
+ color: white;
43
+ border: none;
44
+ border-radius: 4px;
45
+ padding: 4px 8px;
46
+ font-size: 12px;
47
+ cursor: pointer;
48
+ transition: background-color 0.2s;
49
+
50
+ &:hover:not(:disabled) {
51
+ background-color: #c82333;
52
+ }
53
+
54
+ &:disabled {
55
+ background-color: #6c757d;
56
+ cursor: not-allowed;
57
+ }
58
+ }
59
+
31
60
  .diff-files {
32
61
  display: flex;
33
62
  flex-direction: column;
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import {
3
3
  UnifiedFilesDiff,
4
4
  parseUnifiedDiffStats,
@@ -10,11 +10,21 @@ import './DiffView.scss';
10
10
 
11
11
  interface DiffViewProps {
12
12
  diffData: UnifiedFilesDiff;
13
+ messageId?: string;
14
+ chatId?: string;
15
+ beforeFiles?: any;
16
+ canUndo?: boolean;
13
17
  }
14
18
 
15
19
  export const DiffView: React.FC<DiffViewProps> = ({
16
20
  diffData,
21
+ messageId,
22
+ chatId,
23
+ beforeFiles,
24
+ canUndo = false,
17
25
  }) => {
26
+ const [isUndoing, setIsUndoing] = useState(false);
27
+
18
28
  const unifiedDiffs = Object.values(diffData).filter(
19
29
  (diff) => diff.length > 0,
20
30
  );
@@ -23,6 +33,45 @@ export const DiffView: React.FC<DiffViewProps> = ({
23
33
  return null;
24
34
  }
25
35
 
36
+ const handleUndo = async () => {
37
+ if (
38
+ !messageId ||
39
+ !chatId ||
40
+ !beforeFiles ||
41
+ isUndoing
42
+ ) {
43
+ return;
44
+ }
45
+
46
+ setIsUndoing(true);
47
+ try {
48
+ const response = await fetch('/ai-chat-undo', {
49
+ method: 'POST',
50
+ headers: {
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ body: JSON.stringify({
54
+ chatId,
55
+ messageId,
56
+ }),
57
+ });
58
+
59
+ if (!response.ok) {
60
+ throw new Error(
61
+ `HTTP error! status: ${response.status}`,
62
+ );
63
+ }
64
+
65
+ // The server will handle the ShareDB operations to undo the changes
66
+ // The UI will update automatically via ShareDB
67
+ } catch (error) {
68
+ console.error('Error undoing AI edit:', error);
69
+ // TODO: Show user-friendly error message
70
+ } finally {
71
+ setIsUndoing(false);
72
+ }
73
+ };
74
+
26
75
  // Calculate statistics from all unified diffs
27
76
  let totalAdditions = 0;
28
77
  let totalDeletions = 0;
@@ -67,6 +116,18 @@ export const DiffView: React.FC<DiffViewProps> = ({
67
116
  className="diff-files"
68
117
  dangerouslySetInnerHTML={{ __html: diffHtml }}
69
118
  />
119
+ {canUndo && beforeFiles && (
120
+ <div className="undo-button-container">
121
+ <button
122
+ className="undo-button"
123
+ onClick={handleUndo}
124
+ disabled={isUndoing}
125
+ title="Undo this AI edit"
126
+ >
127
+ {isUndoing ? 'Undoing...' : 'Undo'}
128
+ </button>
129
+ </div>
130
+ )}
70
131
  </div>
71
132
  );
72
133
  };