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
@@ -1,8 +1,13 @@
1
1
  import { timestampToDate } from '@vizhub/viz-utils';
2
2
  import Markdown from 'react-markdown';
3
3
  import remarkGfm from 'remark-gfm';
4
- import React, { useMemo, memo, useContext } from 'react';
5
- import { DiffView } from './DiffView';
4
+ import React, {
5
+ useMemo,
6
+ memo,
7
+ useContext,
8
+ forwardRef,
9
+ } from 'react';
10
+ import { DiffView, DiffViewRef } from './DiffView';
6
11
  import { UnifiedFilesDiff } from '../../../utils/fileDiff';
7
12
  import { enableDiffView } from '../../featureFlags';
8
13
  import { VZCodeContext } from '../../VZCodeContext';
@@ -18,60 +23,70 @@ interface MessageProps {
18
23
  showAdditionalWidgets?: boolean;
19
24
  }
20
25
 
21
- const MessageComponent = ({
22
- id,
23
- role,
24
- content,
25
- timestamp,
26
- isStreaming,
27
- diffData,
28
- chatId,
29
- showAdditionalWidgets = false,
30
- }: MessageProps) => {
31
- const { additionalWidgets, handleSendMessage } =
32
- useContext(VZCodeContext);
26
+ const MessageComponent = forwardRef<
27
+ DiffViewRef,
28
+ MessageProps
29
+ >(
30
+ (
31
+ {
32
+ id,
33
+ role,
34
+ content,
35
+ timestamp,
36
+ isStreaming,
37
+ diffData,
38
+ chatId,
39
+ showAdditionalWidgets = false,
40
+ },
41
+ ref,
42
+ ) => {
43
+ const { additionalWidgets, handleSendMessage } =
44
+ useContext(VZCodeContext);
33
45
 
34
- // Memoize date formatting to avoid repeated computation
35
- const formattedTime = useMemo(() => {
36
- return timestampToDate(timestamp).toLocaleTimeString(
37
- [],
38
- {
39
- hour: '2-digit',
40
- minute: '2-digit',
41
- },
42
- );
43
- }, [timestamp]);
46
+ // Memoize date formatting to avoid repeated computation
47
+ const formattedTime = useMemo(() => {
48
+ return timestampToDate(timestamp).toLocaleTimeString(
49
+ [],
50
+ {
51
+ hour: '2-digit',
52
+ minute: '2-digit',
53
+ },
54
+ );
55
+ }, [timestamp]);
44
56
 
45
- // Memoize the className string to avoid recreation
46
- const messageClassName = useMemo(() => {
47
- return `ai-chat-message ${role}${isStreaming ? ' streaming' : ''}`;
48
- }, [role, isStreaming]);
57
+ // Memoize the className string to avoid recreation
58
+ const messageClassName = useMemo(() => {
59
+ return `ai-chat-message ${role}${isStreaming ? ' streaming' : ''}`;
60
+ }, [role, isStreaming]);
49
61
 
50
- return (
51
- <div className={messageClassName}>
52
- <div className="ai-chat-message-content">
53
- <Markdown remarkPlugins={[remarkGfm]}>
54
- {content}
55
- </Markdown>
56
- {enableDiffView &&
57
- diffData &&
58
- Object.keys(diffData).length > 0 && (
59
- <DiffView diffData={diffData} />
60
- )}
61
- {showAdditionalWidgets &&
62
- additionalWidgets &&
63
- chatId &&
64
- additionalWidgets({
65
- messageId: id,
66
- chatId: chatId,
67
- handleSendMessage,
68
- })}
69
- </div>
70
- <div className="ai-chat-message-time">
71
- {formattedTime}
62
+ return (
63
+ <div className={messageClassName}>
64
+ <div className="ai-chat-message-content">
65
+ <Markdown remarkPlugins={[remarkGfm]}>
66
+ {content}
67
+ </Markdown>
68
+ {enableDiffView &&
69
+ diffData &&
70
+ Object.keys(diffData).length > 0 && (
71
+ <DiffView diffData={diffData} ref={ref} />
72
+ )}
73
+ {showAdditionalWidgets &&
74
+ additionalWidgets &&
75
+ chatId &&
76
+ additionalWidgets({
77
+ messageId: id,
78
+ chatId: chatId,
79
+ handleSendMessage,
80
+ })}
81
+ </div>
82
+ <div className="ai-chat-message-time">
83
+ {formattedTime}
84
+ </div>
72
85
  </div>
73
- </div>
74
- );
75
- };
86
+ );
87
+ },
88
+ );
89
+
90
+ MessageComponent.displayName = 'MessageComponent';
76
91
 
77
92
  export const Message = memo(MessageComponent);
@@ -1,141 +1,74 @@
1
- import {
2
- useRef,
3
- useEffect,
4
- useCallback,
5
- memo,
6
- useState,
7
- } from 'react';
1
+ import { useRef, useEffect, memo, useState } from 'react';
8
2
  import { Message } from './Message';
3
+ import { StreamingMessage } from './StreamingMessage';
9
4
  import { TypingIndicator } from './TypingIndicator';
10
5
  import { ThinkingScratchpad } from './ThinkingScratchpad';
6
+ import { AIEditingStatusIndicator } from './FileEditingIndicator';
11
7
  import { VizChatMessage } from '@vizhub/viz-types';
8
+ import { ExtendedVizChatMessage } from '../../../types.js';
9
+ import { DiffViewRef } from './DiffView.js';
12
10
 
13
11
  const MessageListComponent = ({
14
12
  messages,
15
13
  isLoading,
16
14
  chatId, // Add chatId prop
17
15
  aiScratchpad, // Add aiScratchpad prop
16
+ currentStatus, // Add current status prop
17
+ onNewEvent,
18
+ onJumpToLatest,
19
+ beforeRender,
20
+ afterRender,
18
21
  }: {
19
22
  messages: VizChatMessage[];
20
23
  isLoading: boolean;
21
24
  chatId?: string; // Add chatId to the type
22
25
  aiScratchpad?: string; // Add aiScratchpad to the type
26
+ currentStatus?: string; // Add current status to the type
27
+ onNewEvent: (targetElement?: HTMLElement) => void;
28
+ onJumpToLatest: (targetElement?: HTMLElement) => void;
29
+ beforeRender: () => number;
30
+ afterRender: (prevScrollHeight: number) => void;
23
31
  }) => {
24
32
  const messagesEndRef = useRef<HTMLDivElement>(null);
25
- const messagesContainerRef = useRef<HTMLDivElement>(null);
26
- const [isUserScrolled, setIsUserScrolled] =
27
- useState(false);
28
- const [autoScrollEnabled, setAutoScrollEnabled] =
29
- useState(true);
30
- const scrollTimeoutRef =
31
- useRef<ReturnType<typeof setTimeout>>();
32
-
33
- // Check if the user is scrolled to the bottom
34
- const isScrolledToBottom = useCallback(() => {
35
- const container = messagesContainerRef.current;
36
- if (!container) return true;
37
-
38
- const threshold = 50; // Allow 50px tolerance for "at bottom"
39
- const { scrollTop, scrollHeight, clientHeight } =
40
- container;
41
- return (
42
- scrollHeight - scrollTop - clientHeight < threshold
43
- );
44
- }, []);
45
-
46
- // Smooth scroll to bottom with linear transition
47
- const scrollToBottom = useCallback(() => {
48
- if (!autoScrollEnabled) return;
49
-
50
- messagesEndRef.current?.scrollIntoView({
51
- behavior: 'smooth',
52
- block: 'end',
53
- });
54
- }, [autoScrollEnabled]);
55
-
56
- // Handle scroll events to detect user manual scrolling
57
- const handleScroll = useCallback(() => {
58
- const container = messagesContainerRef.current;
59
- if (!container) return;
60
-
61
- const isAtBottom = isScrolledToBottom();
62
-
63
- // Clear any existing timeout
64
- if (scrollTimeoutRef.current) {
65
- clearTimeout(scrollTimeoutRef.current);
66
- }
67
-
68
- // If user scrolled up from bottom, disable auto-scroll
69
- if (!isAtBottom && !isUserScrolled) {
70
- setIsUserScrolled(true);
71
- setAutoScrollEnabled(false);
72
- }
33
+ const diffViewRef = useRef<DiffViewRef>(null);
73
34
 
74
- // If user scrolled back to bottom, re-enable auto-scroll after a brief delay
75
- if (isAtBottom && isUserScrolled) {
76
- scrollTimeoutRef.current = setTimeout(() => {
77
- setIsUserScrolled(false);
78
- setAutoScrollEnabled(true);
79
- }, 500); // 500ms delay to prevent flickering
80
- }
81
- }, [isUserScrolled, isScrolledToBottom]);
35
+ // Track previous loading state to detect when AI generation completes
36
+ const [prevIsLoading, setPrevIsLoading] =
37
+ useState(isLoading);
82
38
 
83
- // Auto-scroll when messages change, but only if auto-scroll is enabled
39
+ // Auto-scroll when messages change
84
40
  useEffect(() => {
85
- if (autoScrollEnabled && !isUserScrolled) {
86
- // Use a short debounce to prevent multiple scroll calls during rapid updates
87
- if (scrollTimeoutRef.current) {
88
- clearTimeout(scrollTimeoutRef.current);
89
- }
90
-
91
- scrollTimeoutRef.current = setTimeout(() => {
92
- scrollToBottom();
93
- }, 100); // 100ms debounce
41
+ // Get scroll height before render for anchoring
42
+ const prevScrollHeight = beforeRender();
43
+
44
+ // Determine if we should scroll to a specific diff or to the bottom
45
+ const lastMessageHasDiff =
46
+ messages.length > 0 &&
47
+ (messages[messages.length - 1] as any).diffData;
48
+
49
+ let targetElement: HTMLElement | null = null;
50
+ if (lastMessageHasDiff && diffViewRef.current) {
51
+ targetElement =
52
+ diffViewRef.current.getFirstHunkElement();
94
53
  }
95
54
 
96
- return () => {
97
- if (scrollTimeoutRef.current) {
98
- clearTimeout(scrollTimeoutRef.current);
99
- }
100
- };
101
- }, [
102
- messages,
103
- autoScrollEnabled,
104
- isUserScrolled,
105
- scrollToBottom,
106
- ]);
55
+ // Trigger auto-scroll if enabled
56
+ onNewEvent(targetElement);
107
57
 
108
- // Track previous loading state to detect when AI generation completes
109
- const [prevIsLoading, setPrevIsLoading] =
110
- useState(isLoading);
58
+ // Adjust scroll position after render for anchoring
59
+ afterRender(prevScrollHeight);
60
+ }, [messages, onNewEvent, beforeRender, afterRender]);
111
61
 
112
62
  // Scroll to bottom when AI generation completes (loading changes from true to false)
113
63
  useEffect(() => {
114
- // If AI was generating and now it's finished, scroll to bottom
64
+ // If AI was generating and now it's finished, force scroll to bottom
115
65
  if (prevIsLoading && !isLoading) {
116
- // Force scroll to bottom regardless of user scroll state
117
- // This ensures we always scroll to bottom when generation completes
118
- messagesEndRef.current?.scrollIntoView({
119
- behavior: 'smooth',
120
- block: 'end',
121
- });
122
-
123
- // Re-enable auto-scroll for future messages
124
- setIsUserScrolled(false);
125
- setAutoScrollEnabled(true);
66
+ // Force jump to latest regardless of current auto-scroll state
67
+ onJumpToLatest();
126
68
  }
127
69
 
128
70
  setPrevIsLoading(isLoading);
129
- }, [isLoading, prevIsLoading]);
130
-
131
- // Clean up timeout on unmount
132
- useEffect(() => {
133
- return () => {
134
- if (scrollTimeoutRef.current) {
135
- clearTimeout(scrollTimeoutRef.current);
136
- }
137
- };
138
- }, []);
71
+ }, [isLoading, prevIsLoading, onJumpToLatest]);
139
72
 
140
73
  // Check if AI generation has started (last message is from assistant)
141
74
  const lastMessage = messages[messages.length - 1];
@@ -152,6 +85,56 @@ const MessageListComponent = ({
152
85
  aiScratchpad && aiScratchpad.trim(),
153
86
  );
154
87
 
88
+ // Determine the single, most relevant status to display with priority:
89
+ // 1. Active file editing status from streaming events
90
+ // 2. General AI status from currentStatus
91
+ const getConsolidatedStatus = () => {
92
+ // Check if there's an active streaming message with file editing
93
+ if (lastMessage && lastMessage.role === 'assistant') {
94
+ const extendedMsg =
95
+ lastMessage as ExtendedVizChatMessage;
96
+ if (
97
+ extendedMsg.streamingEvents &&
98
+ extendedMsg.streamingEvents.length > 0
99
+ ) {
100
+ // Look for active file editing (file_start without corresponding file_complete)
101
+ const fileStates = new Map<string, boolean>();
102
+
103
+ extendedMsg.streamingEvents.forEach((event) => {
104
+ if (event.type === 'file_start') {
105
+ fileStates.set(event.fileName, false); // Mark as editing
106
+ } else if (event.type === 'file_complete') {
107
+ fileStates.set(event.fileName, true); // Mark as complete
108
+ }
109
+ });
110
+
111
+ // Find the first file that's still being edited
112
+ for (const [fileName, isComplete] of fileStates) {
113
+ if (!isComplete) {
114
+ return {
115
+ status: `Editing ${fileName}...`,
116
+ fileName,
117
+ };
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ // Fall back to general AI status if no active file editing
124
+ if (currentStatus) {
125
+ return { status: currentStatus };
126
+ }
127
+
128
+ return null;
129
+ };
130
+
131
+ const consolidatedStatus = getConsolidatedStatus();
132
+
133
+ // Show consolidated status indicator when there's a status to display
134
+ const showConsolidatedStatusIndicator = Boolean(
135
+ consolidatedStatus,
136
+ );
137
+
155
138
  // Find the most recent assistant message index
156
139
  const lastAssistantMessageIndex = messages
157
140
  .map((m, i) => (m.role === 'assistant' ? i : -1))
@@ -161,15 +144,56 @@ const MessageListComponent = ({
161
144
  return (
162
145
  <div
163
146
  className="ai-chat-messages"
164
- ref={messagesContainerRef}
165
- onScroll={handleScroll}
147
+ role="log"
148
+ aria-live="polite"
149
+ aria-relevant="additions"
166
150
  >
167
151
  {messages.map((msg, index) => {
152
+ const isLastMessage = index === messages.length - 1;
153
+ // Cast to extended message to check for streaming events
154
+ const extendedMsg = msg as ExtendedVizChatMessage;
155
+
156
+ // Check if this is a streaming message
157
+ const isStreamingMessage =
158
+ !!extendedMsg.isProgressive;
159
+
168
160
  // Only show additionalWidgets for the most recent assistant message
169
161
  const showAdditionalWidgets =
170
162
  msg.role === 'assistant' &&
171
163
  index === lastAssistantMessageIndex;
172
164
 
165
+ // Use StreamingMessage for assistant messages with streaming events
166
+ if (
167
+ msg.role === 'assistant' &&
168
+ isStreamingMessage
169
+ ) {
170
+ return (
171
+ <StreamingMessage
172
+ key={msg.id}
173
+ timestamp={msg.timestamp}
174
+ events={extendedMsg.streamingEvents || []}
175
+ isActive={index === lastAssistantMessageIndex}
176
+ >
177
+ {showThinkingScratchpad && (
178
+ <ThinkingScratchpad
179
+ content={aiScratchpad || ''}
180
+ isVisible={showThinkingScratchpad}
181
+ />
182
+ )}
183
+
184
+ {showConsolidatedStatusIndicator && (
185
+ <AIEditingStatusIndicator
186
+ status={consolidatedStatus?.status || ''}
187
+ fileName={consolidatedStatus?.fileName}
188
+ />
189
+ )}
190
+
191
+ {showTypingIndicator && <TypingIndicator />}
192
+ </StreamingMessage>
193
+ );
194
+ }
195
+
196
+ // Use regular Message component for user messages and non-streaming assistant messages
173
197
  return (
174
198
  <Message
175
199
  key={msg.id}
@@ -180,18 +204,15 @@ const MessageListComponent = ({
180
204
  diffData={(msg as any).diffData}
181
205
  chatId={chatId}
182
206
  showAdditionalWidgets={showAdditionalWidgets}
207
+ ref={
208
+ isLastMessage && (msg as any).diffData
209
+ ? diffViewRef
210
+ : null
211
+ }
183
212
  />
184
213
  );
185
214
  })}
186
215
 
187
- {showThinkingScratchpad && (
188
- <ThinkingScratchpad
189
- content={aiScratchpad || ''}
190
- isVisible={showThinkingScratchpad}
191
- />
192
- )}
193
-
194
- {showTypingIndicator && <TypingIndicator />}
195
216
  <div ref={messagesEndRef} />
196
217
  </div>
197
218
  );
@@ -1,35 +1,77 @@
1
+ import React, { useMemo } from 'react';
2
+ import { timestampToDate } from '@vizhub/viz-utils';
1
3
  import Markdown from 'react-markdown';
2
4
  import remarkGfm from 'remark-gfm';
3
- import { memo } from 'react';
5
+ import { StreamingEvent } from '../../../types.js';
6
+ import { IndividualFileDiff } from './IndividualFileDiff';
7
+
8
+ const DEBUG = false;
4
9
 
5
10
  interface StreamingMessageProps {
6
- content: string;
7
- status?: string;
11
+ timestamp: number;
12
+ events: StreamingEvent[];
13
+ isActive?: boolean; // Is this the currently streaming message?
14
+ children?: React.ReactNode;
8
15
  }
9
16
 
10
- const StreamingMessageComponent = ({
11
- content,
12
- status,
13
- }: StreamingMessageProps) => {
14
- // Don't render if there's no content and no status
15
- if (!content.trim()) {
16
- return null;
17
- }
17
+ export const StreamingMessage: React.FC<
18
+ StreamingMessageProps
19
+ > = ({ timestamp, events, isActive, children }) => {
20
+ DEBUG &&
21
+ console.log(
22
+ 'StreamingMessage: Rendered with events:',
23
+ events,
24
+ );
25
+
26
+ // Memoize date formatting to avoid repeated computation
27
+ const formattedTime = useMemo(() => {
28
+ return timestampToDate(timestamp).toLocaleTimeString(
29
+ [],
30
+ {
31
+ hour: '2-digit',
32
+ minute: '2-digit',
33
+ },
34
+ );
35
+ }, [timestamp]);
18
36
 
19
37
  return (
20
38
  <div className="ai-chat-message assistant streaming">
21
- {status && (
22
- <div className="ai-chat-status">{status}</div>
23
- )}
24
39
  <div className="ai-chat-message-content">
25
- <Markdown remarkPlugins={[remarkGfm]}>
26
- {content}
27
- </Markdown>
40
+ {/* Render events in order */}
41
+ {events.map((event, index) => {
42
+ switch (event.type) {
43
+ case 'text_chunk':
44
+ return (
45
+ <div
46
+ key={`text-${index}`}
47
+ className="text-chunk"
48
+ >
49
+ <Markdown remarkPlugins={[remarkGfm]}>
50
+ {event.content}
51
+ </Markdown>
52
+ </div>
53
+ );
54
+ case 'file_complete':
55
+ return (
56
+ <IndividualFileDiff
57
+ key={`file-${event.fileName}-${index}`}
58
+ fileName={event.fileName}
59
+ beforeContent={event.beforeContent || ''}
60
+ afterContent={event.afterContent || ''}
61
+ />
62
+ );
63
+ case 'file_start':
64
+ // File start events are now handled by centralized status logic in MessageList
65
+ return null;
66
+ default:
67
+ return null;
68
+ }
69
+ })}
70
+ {isActive && children}
71
+ </div>
72
+ <div className="ai-chat-message-time">
73
+ {formattedTime}
28
74
  </div>
29
75
  </div>
30
76
  );
31
77
  };
32
-
33
- export const StreamingMessage = memo(
34
- StreamingMessageComponent,
35
- );