vzcode 1.56.0 → 1.58.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.
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-Cp6YlJo2.js"></script>
24
- <link rel="stylesheet" crossorigin href="/assets/index-COSAnnol.css">
23
+ <script type="module" crossorigin src="/assets/index-CtGus8Qk.js"></script>
24
+ <link rel="stylesheet" crossorigin href="/assets/index-CE_xP06t.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.56.0",
3
+ "version": "1.58.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "test": "vitest run",
14
- "test-interactive": "cd test/sampleDirectories/kitchenSink && node --import 'data:text/javascript,import { register } from \"node:module\"; import { pathToFileURL } from \"node:url\"; register(\"ts-node/esm\", pathToFileURL(\"./\"));' ../../../src/server/index.ts",
14
+ "test-interactive": "cd test/sampleDirectories/kitchenSink && node --import \"data:text/javascript,import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; register('ts-node/esm', pathToFileURL('./'));\" ../../../src/server/index.ts",
15
15
  "prettier": "prettier {*.*,**/*.*} --write",
16
16
  "typecheck": "tsc --noEmit",
17
17
  "dev": "cross-env EDITOR_PORT=5173 concurrently \"npm run test-interactive\" \"vite\"",
@@ -186,7 +186,7 @@
186
186
  "globals": "^16.3.0",
187
187
  "npm-check-updates": "^18.0.2",
188
188
  "prettier": "^3.6.2",
189
- "sass": "^1.89.2",
189
+ "sass": "^1.90.0",
190
190
  "ts-node": "^10.9.2",
191
191
  "typescript": "^5.9.2",
192
192
  "vite": "^7.0.6",
@@ -74,6 +74,7 @@ import { getFileExtension } from '../utils/fileExtension';
74
74
  import { SparklesSVG } from '../Icons/SparklesSVG';
75
75
  import { VZCodeContext } from '../VZCodeContext';
76
76
  import { useContext, useMemo } from 'react';
77
+ import { handleAIChatMessage } from '../../server/aiChatHandler';
77
78
 
78
79
  const DEBUG = false;
79
80
 
@@ -194,6 +195,7 @@ export const getOrCreateEditor = async ({
194
195
  rainbowBracketsEnabled = true,
195
196
  setIsAIChatOpen,
196
197
  setAIChatMessage,
198
+ handleSendMessage,
197
199
  }: {
198
200
  // TODO pass this in from the outside
199
201
  paneId?: PaneId;
@@ -229,8 +231,9 @@ export const getOrCreateEditor = async ({
229
231
  view: EditorView,
230
232
  ) => Promise<readonly Diagnostic[]>;
231
233
  rainbowBracketsEnabled?: boolean; // New parameter type
232
- setIsAIChatOpen: any; // TODO fix types
233
- setAIChatMessage: any; // TODO fix types
234
+ setIsAIChatOpen: (isAIChatOpen: boolean) => void;
235
+ setAIChatMessage: (message: string) => void;
236
+ handleSendMessage: any; // TODO fix types
234
237
  }): Promise<ExtendedEditorCacheValue> => {
235
238
  // Cache hit
236
239
 
@@ -500,12 +503,9 @@ export const getOrCreateEditor = async ({
500
503
  root.render(
501
504
  <div
502
505
  onClick={() => {
503
- console.log(
504
- 'TODO set the chat prompt to "Implement the TODO"',
505
- );
506
506
  setIsAIChatOpen(true);
507
- setAIChatMessage('Implement the TODO');
508
- // submitAIChatMessage()
507
+ // setAIChatMessage('Implement the TODO');
508
+ handleSendMessage('Implement the TODO');
509
509
  }}
510
510
  >
511
511
  <SparklesSVG width={14} height={14} />
@@ -45,6 +45,7 @@ export const CodeEditor = ({
45
45
  enableAutoFollow,
46
46
  setIsAIChatOpen,
47
47
  setAIChatMessage,
48
+ handleSendMessage,
48
49
  } = useContext(VZCodeContext);
49
50
 
50
51
  // Set `doc.data.isInteracting` to `true` when the user is interacting
@@ -116,6 +117,7 @@ export const CodeEditor = ({
116
117
  esLintSource,
117
118
  setIsAIChatOpen,
118
119
  setAIChatMessage,
120
+ handleSendMessage,
119
121
  });
120
122
 
121
123
  if (isMounted) {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Context,
2
3
  createContext,
3
4
  useCallback,
4
5
  useReducer,
@@ -39,6 +40,7 @@ import { useURLSync } from './useURLSync';
39
40
  import { createInitialState, vzReducer } from './vzReducer';
40
41
  import { findPane } from './vzReducer/findPane';
41
42
  import { usePresenceAutoFollow } from './usePresenceAutoFollow';
43
+ import { v4 as uuidv4 } from 'uuid';
42
44
 
43
45
  // This context centralizes all the "smart" logic
44
46
  // to do with the application state. This includes
@@ -195,6 +197,12 @@ export type VZCodeContextValue = {
195
197
  setVoiceChatModalOpen: (state: boolean) => void;
196
198
  aiChatMessage: string;
197
199
  setAIChatMessage: (message: string) => void;
200
+ isLoading: boolean;
201
+ setIsLoading: (state: boolean) => void;
202
+ currentChatId: string;
203
+ aiErrorMessage: string | null;
204
+ setAIErrorMessage: (state: string | null) => void;
205
+ handleSendMessage: (messageToSend?: string) => void;
198
206
 
199
207
  // Auto-fork functions for VizHub integration
200
208
  autoForkAndRetryAI?: (
@@ -207,6 +215,13 @@ export type VZCodeContextValue = {
207
215
  prompt: string;
208
216
  modelName: string;
209
217
  } | null;
218
+
219
+ // Additional widgets that can be rendered in AI chat messages
220
+ additionalWidgets?: React.ComponentType<{
221
+ messageId: string;
222
+ chatId: string;
223
+ canUndo: boolean;
224
+ }>;
210
225
  };
211
226
 
212
227
  export const VZCodeProvider = ({
@@ -233,6 +248,7 @@ export const VZCodeProvider = ({
233
248
  autoForkAndRetryAI,
234
249
  clearStoredAIPrompt,
235
250
  getStoredAIPrompt,
251
+ additionalWidgets,
236
252
  }: {
237
253
  content: VizContent;
238
254
  shareDBDoc: ShareDBDoc<VizContent>;
@@ -264,6 +280,11 @@ export const VZCodeProvider = ({
264
280
  prompt: string;
265
281
  modelName: string;
266
282
  } | null;
283
+ additionalWidgets?: React.ComponentType<{
284
+ messageId: string;
285
+ chatId: string;
286
+ canUndo: boolean;
287
+ }>;
267
288
  }) => {
268
289
  // Auto-run Pretter after local changes.
269
290
  const { prettierError, runPrettierRef } = usePrettier({
@@ -324,6 +345,7 @@ export const VZCodeProvider = ({
324
345
  username,
325
346
  enableAutoFollow,
326
347
  sidebarPresenceIndicators,
348
+ aiChatMode,
327
349
  } = state;
328
350
 
329
351
  const activePane: Pane = findPane(pane, activePaneId);
@@ -479,6 +501,120 @@ export const VZCodeProvider = ({
479
501
 
480
502
  const [aiChatMessage, setAIChatMessage] = useState('');
481
503
 
504
+ const DEBUG = false;
505
+
506
+ const [isLoading, setIsLoading] = useState(false);
507
+ const [currentChatId] = useState(() => uuidv4());
508
+ const [aiErrorMessage, setAIErrorMessage] = useState<
509
+ string | null
510
+ >(null);
511
+
512
+ const handleSendMessage = useCallback(
513
+ async (messageToSend?: string) => {
514
+ DEBUG &&
515
+ console.log(
516
+ 'AIChat: handleSendMessage called with:',
517
+ messageToSend,
518
+ 'aiChatMessage:',
519
+ aiChatMessage,
520
+ );
521
+ const messageContent = messageToSend || aiChatMessage;
522
+
523
+ if (
524
+ !messageContent ||
525
+ typeof messageContent !== 'string' ||
526
+ !messageContent.trim() ||
527
+ isLoading
528
+ ) {
529
+ return;
530
+ }
531
+
532
+ const currentPrompt = aiChatMessage.trim();
533
+ setAIChatMessage('');
534
+ setIsLoading(true);
535
+ setAIErrorMessage(null); // Clear any previous errors
536
+
537
+ // Call backend endpoint for AI response
538
+ // The server will handle all ShareDB operations including adding the user message
539
+ try {
540
+ const response = await fetch(aiChatEndpoint, {
541
+ method: 'POST',
542
+ headers: {
543
+ 'Content-Type': 'application/json',
544
+ },
545
+ body: JSON.stringify({
546
+ ...aiChatOptions,
547
+ vizId: aiChatOptions.vizId,
548
+ content: messageContent.trim(),
549
+ chatId: currentChatId,
550
+ mode: aiChatMode,
551
+ }),
552
+ });
553
+
554
+ if (!response.ok) {
555
+ throw new Error(
556
+ `HTTP error! status: ${response.status}`,
557
+ );
558
+ }
559
+
560
+ // Parse the response to check for errors
561
+ const responseData = await response.json();
562
+
563
+ // Check if the response contains a VizHub error
564
+ if (
565
+ responseData.outcome === 'failure' &&
566
+ responseData.error
567
+ ) {
568
+ const errorMessage = responseData.error.message;
569
+
570
+ // Check if this is the specific permission error that should trigger auto-fork
571
+ if (
572
+ aiErrorMessage ===
573
+ 'You do not have permission to use AI chat on this visualization. Only users with edit access can use this feature. Fork the viz to edit it.'
574
+ ) {
575
+ // Trigger auto-fork instead of showing error
576
+ try {
577
+ await autoForkAndRetryAI?.(
578
+ currentPrompt,
579
+ aiChatMode,
580
+ );
581
+ // If we reach here, the fork was successful and redirect should happen
582
+ return;
583
+ } catch (forkError) {
584
+ console.error('Auto-fork failed:', forkError);
585
+ setAIErrorMessage(
586
+ 'Failed to fork visualization. Please try forking manually.',
587
+ );
588
+ return;
589
+ }
590
+ }
591
+
592
+ // For other errors, show the error message
593
+ setAIErrorMessage(aiErrorMessage);
594
+ return;
595
+ }
596
+
597
+ // The backend handles all ShareDB operations for successful responses
598
+ } catch (error) {
599
+ console.error('Error getting AI response:', error);
600
+ setAIErrorMessage(
601
+ 'Failed to send message. Please try again.',
602
+ );
603
+ } finally {
604
+ setIsLoading(false);
605
+ }
606
+ },
607
+ [
608
+ aiChatMessage,
609
+ isLoading,
610
+ aiChatEndpoint,
611
+ aiChatOptions,
612
+ currentChatId,
613
+ aiChatMode,
614
+ autoForkAndRetryAI,
615
+ ],
616
+ );
617
+
482
618
  // The value provided by this context.
483
619
  const value: VZCodeContextValue = {
484
620
  content,
@@ -589,11 +725,20 @@ export const VZCodeProvider = ({
589
725
 
590
726
  aiChatMessage,
591
727
  setAIChatMessage,
728
+ isLoading,
729
+ setIsLoading,
730
+ currentChatId,
731
+ aiErrorMessage,
732
+ setAIErrorMessage,
733
+ handleSendMessage,
592
734
 
593
735
  // Auto-fork functions for VizHub integration
594
736
  autoForkAndRetryAI,
595
737
  clearStoredAIPrompt,
596
738
  getStoredAIPrompt,
739
+
740
+ // Additional widgets that can be rendered in AI chat messages
741
+ additionalWidgets,
597
742
  };
598
743
 
599
744
  return (
@@ -112,7 +112,7 @@ const ChatInputComponent = ({
112
112
  <Form.Group className="ai-chat-input-group">
113
113
  <Form.Control
114
114
  as="textarea"
115
- rows={2}
115
+ rows={5}
116
116
  value={aiChatMessage}
117
117
  onChange={handleChange}
118
118
  onKeyDown={handleKeyDown}
@@ -126,26 +126,25 @@ const ChatInputComponent = ({
126
126
  disabled={isLoading}
127
127
  aria-label="Chat message input"
128
128
  />
129
- {aiChatMessage && (
130
- <div className="ai-chat-input-info">
131
- <span className="ai-chat-hint">
132
- Shift+Enter for new line
133
- </span>
134
- <span className="ai-chat-hint">
135
- Press Enter to send
136
- </span>
137
- </div>
138
- )}
139
- <Button
140
- variant="primary"
141
- onClick={handleSendClick}
142
- disabled={!aiChatMessage.trim() || isLoading}
143
- className="ai-chat-send-button"
144
- aria-label="Send message"
145
- title="Send message (Enter)"
146
- >
147
- Send
148
- </Button>
129
+ <div className="ai-chat-input-footer">
130
+ <span className="ai-chat-hint">
131
+ {aiChatMessage ? 'Press Enter to send' : ''}
132
+ </span>
133
+ <Button
134
+ variant={
135
+ aiChatMessage.trim()
136
+ ? 'primary'
137
+ : 'outline-secondary'
138
+ }
139
+ onClick={handleSendClick}
140
+ disabled={!aiChatMessage.trim() || isLoading}
141
+ className="ai-chat-send-button"
142
+ aria-label="Send message"
143
+ title="Send message (Enter)"
144
+ >
145
+ Send
146
+ </Button>
147
+ </div>
149
148
  </Form.Group>
150
149
  </div>
151
150
  );
@@ -119,6 +119,14 @@
119
119
  border-color: var(--d2h-ins-border-color) !important;
120
120
  }
121
121
 
122
+ // Style clickable file names
123
+ .d2h-file-name {
124
+ &:hover {
125
+ text-decoration: underline !important;
126
+ opacity: 0.8;
127
+ }
128
+ }
129
+
122
130
  // Fix wrapper border
123
131
  .d2h-file-wrapper {
124
132
  border: 1px solid var(--d2h-border-color) !important;
@@ -1,4 +1,9 @@
1
- import React from 'react';
1
+ import React, {
2
+ useContext,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ } from 'react';
2
7
  import {
3
8
  UnifiedFilesDiff,
4
9
  parseUnifiedDiffStats,
@@ -7,6 +12,8 @@ import {
7
12
  import * as Diff2Html from 'diff2html';
8
13
  import 'diff2html/bundles/css/diff2html.min.css';
9
14
  import './DiffView.scss';
15
+ import { VZCodeContext } from '../../VZCodeContext';
16
+ import { VizFileId, VizFiles } from '@vizhub/viz-types';
10
17
 
11
18
  interface DiffViewProps {
12
19
  diffData: UnifiedFilesDiff;
@@ -15,6 +22,10 @@ interface DiffViewProps {
15
22
  export const DiffView: React.FC<DiffViewProps> = ({
16
23
  diffData,
17
24
  }) => {
25
+ const { content, openTab, setIsAIChatOpen } =
26
+ useContext(VZCodeContext);
27
+ const diffContainerRef = useRef<HTMLDivElement>(null);
28
+
18
29
  const unifiedDiffs = Object.values(diffData).filter(
19
30
  (diff) => diff.length > 0,
20
31
  );
@@ -42,6 +53,98 @@ export const DiffView: React.FC<DiffViewProps> = ({
42
53
  outputFormat: 'line-by-line',
43
54
  });
44
55
 
56
+ // Create a mapping from file name to file ID for click handling
57
+ const fileNameToIdMap = useMemo(() => {
58
+ const map = new Map<string, VizFileId>();
59
+ Object.entries(diffData).forEach(([fileId, diff]) => {
60
+ // Extract file name from the unified diff
61
+ // The diff contains lines like "--- a/filename" and "+++ b/filename"
62
+ const lines = diff.split('\n');
63
+ for (const line of lines) {
64
+ if (
65
+ line.startsWith('--- a/') ||
66
+ line.startsWith('+++ b/')
67
+ ) {
68
+ const fileName = line.substring(6); // Remove "--- a/" or "+++ b/"
69
+ map.set(fileName, fileId as VizFileId);
70
+ break;
71
+ }
72
+ }
73
+ });
74
+ return map;
75
+ }, [diffData]);
76
+
77
+ // Add click handlers to file names after HTML is rendered
78
+ useEffect(() => {
79
+ if (!diffContainerRef.current) return;
80
+
81
+ const handleFileNameClick = (event: MouseEvent) => {
82
+ const target = event.target as HTMLElement;
83
+ if (target.classList.contains('d2h-file-name')) {
84
+ event.preventDefault();
85
+ event.stopPropagation();
86
+
87
+ console.log('File name clicked');
88
+
89
+ const fileName = target.textContent?.trim();
90
+
91
+ console.log('Clicked file name:', fileName);
92
+ if (fileName) {
93
+ const files: VizFiles = content?.files;
94
+ if (files) {
95
+ // export type VizFiles = {
96
+ // [fileId: VizFileId]: VizFile;
97
+ // };
98
+ // export type VizFileId = string;
99
+ // export type VizFile = {
100
+ // name: string;
101
+ // text: string;
102
+ // };
103
+
104
+ // TODO get fileId from content.files
105
+ const fileId = Object.entries(files).find(
106
+ ([id, file]) => file.name === fileName,
107
+ )?.[0];
108
+ console.log('Mapped file ID:', fileId);
109
+
110
+ if (fileId) {
111
+ // Open the file tab and switch to files view
112
+ openTab({ fileId, isTransient: false });
113
+ setIsAIChatOpen(false);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ };
119
+
120
+ const container = diffContainerRef.current;
121
+ const fileNameElements = container.querySelectorAll(
122
+ '.d2h-file-name',
123
+ );
124
+
125
+ // Add click event listeners and cursor pointer style
126
+ fileNameElements.forEach((element) => {
127
+ element.addEventListener(
128
+ 'click',
129
+ handleFileNameClick,
130
+ );
131
+ (element as HTMLElement).style.cursor = 'pointer';
132
+ (element as HTMLElement).style.textDecoration =
133
+ 'underline';
134
+ (element as HTMLElement).style.color = '#58a6ff'; // GitHub blue link color
135
+ });
136
+
137
+ return () => {
138
+ // Cleanup event listeners
139
+ fileNameElements.forEach((element) => {
140
+ element.removeEventListener(
141
+ 'click',
142
+ handleFileNameClick,
143
+ );
144
+ });
145
+ };
146
+ }, [diffHtml, content, openTab, setIsAIChatOpen]);
147
+
45
148
  return (
46
149
  <div className="diff-view">
47
150
  <div className="diff-summary">
@@ -65,6 +168,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
65
168
 
66
169
  <div
67
170
  className="diff-files"
171
+ ref={diffContainerRef}
68
172
  dangerouslySetInnerHTML={{ __html: diffHtml }}
69
173
  />
70
174
  </div>
@@ -1,7 +1,12 @@
1
1
  import { timestampToDate } from '@vizhub/viz-utils';
2
2
  import Markdown from 'react-markdown';
3
3
  import remarkGfm from 'remark-gfm';
4
- import { useMemo, memo, useState, useContext } from 'react';
4
+ import React, {
5
+ useMemo,
6
+ memo,
7
+ useState,
8
+ useContext,
9
+ } from 'react';
5
10
  import { DiffView } from './DiffView';
6
11
  import { UnifiedFilesDiff } from '../../../utils/fileDiff';
7
12
  import { enableDiffView } from '../../featureFlags';
@@ -31,8 +36,11 @@ const MessageComponent = ({
31
36
  canUndo,
32
37
  }: MessageProps) => {
33
38
  const [isUndoing, setIsUndoing] = useState(false);
34
- const { aiChatUndoEndpoint, aiChatOptions = {} } =
35
- useContext(VZCodeContext);
39
+ const {
40
+ aiChatUndoEndpoint,
41
+ aiChatOptions = {},
42
+ additionalWidgets,
43
+ } = useContext(VZCodeContext);
36
44
 
37
45
  // Memoize date formatting to avoid repeated computation
38
46
  const formattedTime = useMemo(() => {
@@ -102,7 +110,15 @@ const MessageComponent = ({
102
110
  Object.keys(diffData).length > 0 && (
103
111
  <DiffView diffData={diffData} />
104
112
  )}
105
- {canUndo && beforeFiles && (
113
+ {additionalWidgets &&
114
+ canUndo &&
115
+ chatId &&
116
+ React.createElement(additionalWidgets, {
117
+ messageId: id,
118
+ chatId: chatId,
119
+ canUndo: canUndo,
120
+ })}
121
+ {!additionalWidgets && canUndo && beforeFiles && (
106
122
  <div className="undo-button-container">
107
123
  <button
108
124
  className="undo-button"
@@ -12,7 +12,7 @@ const StreamingMessageComponent = ({
12
12
  status,
13
13
  }: StreamingMessageProps) => {
14
14
  // Don't render if there's no content and no status
15
- if (!content.trim() && !status) {
15
+ if (!content.trim()) {
16
16
  return null;
17
17
  }
18
18