vzcode 2.18.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 (57) hide show
  1. package/README.md +359 -0
  2. package/dist/assets/index-CfDs-dAu.js +476 -0
  3. package/dist/assets/{index-BvGPtSrr.css → index-fSroLVgi.css} +1 -1
  4. package/dist/assets/{worker-y5jhqCTR.js → worker-Cm3I3tnR.js} +57 -57
  5. package/dist/assets/{worker-ClF0pBYr.js → worker-KiuLM-X4.js} +71 -71
  6. package/dist/index.html +2 -2
  7. package/dist/llm-streaming-server/aiEditing.js +58 -0
  8. package/dist/llm-streaming-server/chatOperations.js +494 -0
  9. package/dist/llm-streaming-server/errorHandling.js +49 -0
  10. package/dist/llm-streaming-server/index.js +20 -0
  11. package/dist/llm-streaming-server/llmStreaming.js +265 -0
  12. package/dist/llm-streaming-server/validation.js +19 -0
  13. package/dist/server/aiChatHandler/chatOperations.js +3 -4
  14. package/dist/server/aiChatHandler/index.js +5 -5
  15. package/dist/server/aiChatHandler/llmStreaming.js +57 -4
  16. package/dist/server/prettier.js +18 -16
  17. package/dist/utils/fileDiff.js +25 -1
  18. package/package.json +45 -45
  19. package/src/client/CodeEditor/getOrCreateEditor.tsx +272 -258
  20. package/src/client/CodeEditor/index.tsx +3 -3
  21. package/src/client/VZRight.tsx +4 -0
  22. package/src/client/VZSidebar/AIChat/ChatInput.tsx +1 -1
  23. package/src/client/VZSidebar/AIChat/DiffView.scss +37 -1
  24. package/src/client/VZSidebar/AIChat/DiffView.tsx +55 -16
  25. package/src/client/VZSidebar/AIChat/styles.scss +38 -0
  26. package/src/client/VZSidebar/FileTypeIcon.tsx +1 -0
  27. package/src/client/VZSidebar/index.tsx +1 -1
  28. package/src/client/featureFlags.ts +7 -0
  29. package/src/llm-streaming-server/README.md +71 -0
  30. package/src/llm-streaming-server/aiEditing.ts +102 -0
  31. package/src/llm-streaming-server/chatOperations.ts +655 -0
  32. package/src/llm-streaming-server/errorHandling.ts +66 -0
  33. package/src/llm-streaming-server/index.ts +51 -0
  34. package/src/llm-streaming-server/llmStreaming.ts +403 -0
  35. package/src/llm-streaming-server/validation.ts +24 -0
  36. package/src/llm-streaming-ui/README.md +103 -0
  37. package/src/llm-streaming-ui/components/ChatInput.tsx +237 -0
  38. package/src/llm-streaming-ui/components/DiffView.scss +173 -0
  39. package/src/llm-streaming-ui/components/DiffView.tsx +236 -0
  40. package/src/llm-streaming-ui/components/FileEditingIndicator.tsx +89 -0
  41. package/src/llm-streaming-ui/components/IndividualFileDiff.tsx +86 -0
  42. package/src/llm-streaming-ui/components/JumpToLatestButton.tsx +64 -0
  43. package/src/llm-streaming-ui/components/Message.tsx +145 -0
  44. package/src/llm-streaming-ui/components/MessageList.tsx +241 -0
  45. package/src/llm-streaming-ui/components/ThinkingScratchpad.tsx +42 -0
  46. package/src/llm-streaming-ui/components/TypingIndicator.tsx +19 -0
  47. package/src/llm-streaming-ui/components/index.tsx +388 -0
  48. package/src/llm-streaming-ui/components/styles.scss +831 -0
  49. package/src/llm-streaming-ui/components/useSpeechRecognition.ts +116 -0
  50. package/src/llm-streaming-ui/index.ts +34 -0
  51. package/src/server/aiChatHandler/chatOperations.ts +3 -4
  52. package/src/server/aiChatHandler/index.ts +5 -5
  53. package/src/server/aiChatHandler/llmStreaming.ts +87 -4
  54. package/src/server/prettier.ts +30 -31
  55. package/src/types.ts +5 -0
  56. package/src/utils/fileDiff.ts +36 -2
  57. package/dist/assets/index-DuDXdJWC.js +0 -477
@@ -0,0 +1,89 @@
1
+ import { Spinner } from '../../client/AIAssist/Spinner';
2
+
3
+ interface AIEditingStatusIndicatorProps {
4
+ status: string;
5
+ fileName?: string;
6
+ additionalWidgets?: React.ReactNode;
7
+ }
8
+
9
+ export const AIEditingStatusIndicator: React.FC<
10
+ AIEditingStatusIndicatorProps
11
+ > = ({ status, fileName, additionalWidgets }) => {
12
+ const getStatusDisplay = (status: string) => {
13
+ switch (status) {
14
+ case 'Analyzing request...':
15
+ return (
16
+ <>
17
+ 🔍 <span>Analyzing request...</span>
18
+ </>
19
+ );
20
+ case 'Formulating a plan...':
21
+ return (
22
+ <>
23
+ 💭 <span>Formulating a plan...</span>
24
+ </>
25
+ );
26
+ case 'Describing changes...':
27
+ return (
28
+ <>
29
+ 📝 <span>Describing changes...</span>
30
+ </>
31
+ );
32
+ case 'Thinking...':
33
+ return (
34
+ <>
35
+ 🤔 <span>Thinking...</span>
36
+ </>
37
+ );
38
+ case 'Done':
39
+ return (
40
+ <div className="file-editing-done-container">
41
+ <div className="file-editing-done-status">
42
+ ✅ <span>Done</span>
43
+ </div>
44
+ {additionalWidgets && (
45
+ <div className="file-editing-done-widgets">
46
+ {additionalWidgets}
47
+ </div>
48
+ )}
49
+ </div>
50
+ );
51
+ default:
52
+ // Handle file editing status (e.g., "Editing filename.js...")
53
+ if (status.startsWith('Editing ') && fileName) {
54
+ return (
55
+ <>
56
+ ✏️{' '}
57
+ <span>
58
+ Editing <code>{fileName}</code>...
59
+ </span>
60
+ </>
61
+ );
62
+ } else if (status.startsWith('Editing ')) {
63
+ return (
64
+ <>
65
+ ✏️ <span>{status}</span>
66
+ </>
67
+ );
68
+ }
69
+ return <span>{status}</span>;
70
+ }
71
+ };
72
+
73
+ const showSpinner = status !== 'Done';
74
+
75
+ return (
76
+ <div className="file-editing-indicator">
77
+ <div className="file-editing-header">
78
+ {showSpinner && (
79
+ <div className="file-editing-icon">
80
+ <Spinner height={16} fadeIn={false} />
81
+ </div>
82
+ )}
83
+ <div className="file-editing-text">
84
+ {getStatusDisplay(status)}
85
+ </div>
86
+ </div>
87
+ </div>
88
+ );
89
+ };
@@ -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
+ };
@@ -0,0 +1,145 @@
1
+ import React, {
2
+ useMemo,
3
+ useContext,
4
+ forwardRef,
5
+ } from 'react';
6
+ import { timestampToDate } from '@vizhub/viz-utils';
7
+ import Markdown from 'react-markdown';
8
+ import remarkGfm from 'remark-gfm';
9
+ import { StreamingEvent } from '../../types.js';
10
+ import { IndividualFileDiff } from './IndividualFileDiff';
11
+ import { VZCodeContext } from '../../client/VZCodeContext';
12
+ import { DiffView, DiffViewRef } from './DiffView';
13
+ import { UnifiedFilesDiff } from '../../utils/fileDiff';
14
+ import { enableDiffView } from '../../client/featureFlags';
15
+
16
+ const DEBUG = false;
17
+
18
+ interface MessageProps {
19
+ id: string;
20
+ role: 'user' | 'assistant';
21
+ content?: string; // For non-streaming messages
22
+ timestamp: number;
23
+ events?: StreamingEvent[]; // For streaming messages
24
+ isActive?: boolean; // Is this the currently streaming message?
25
+ chatId?: string;
26
+ showAdditionalWidgets?: boolean;
27
+ isStreaming?: boolean;
28
+ diffData?: UnifiedFilesDiff;
29
+ }
30
+
31
+ export const Message = forwardRef<
32
+ DiffViewRef,
33
+ React.PropsWithChildren<MessageProps>
34
+ >(
35
+ (
36
+ {
37
+ id,
38
+ role,
39
+ content,
40
+ timestamp,
41
+ events = [],
42
+ isActive,
43
+ children,
44
+ chatId,
45
+ showAdditionalWidgets = false,
46
+ isStreaming = false,
47
+ diffData,
48
+ },
49
+ ref,
50
+ ) => {
51
+ const { additionalWidgets, handleSendMessage } =
52
+ useContext(VZCodeContext);
53
+
54
+ DEBUG &&
55
+ console.log(
56
+ 'StreamingMessage: Rendered with events:',
57
+ events,
58
+ );
59
+
60
+ // Memoize date formatting to avoid repeated computation
61
+ const formattedTime = useMemo(() => {
62
+ return timestampToDate(timestamp).toLocaleTimeString(
63
+ [],
64
+ {
65
+ hour: '2-digit',
66
+ minute: '2-digit',
67
+ },
68
+ );
69
+ }, [timestamp]);
70
+
71
+ // Memoize the className string to avoid recreation
72
+ const messageClassName = useMemo(() => {
73
+ return `ai-chat-message ${role}${isStreaming ? ' streaming' : ''}`;
74
+ }, [role, isStreaming]);
75
+
76
+ return (
77
+ <div className={messageClassName}>
78
+ <div className="ai-chat-message-content">
79
+ {/* Render regular content for non-streaming messages */}
80
+ {content && (
81
+ <Markdown remarkPlugins={[remarkGfm]}>
82
+ {content}
83
+ </Markdown>
84
+ )}
85
+
86
+ {/* Render diff view if present */}
87
+ {enableDiffView &&
88
+ diffData &&
89
+ Object.keys(diffData).length > 0 && (
90
+ <DiffView diffData={diffData} ref={ref} />
91
+ )}
92
+
93
+ {/* Render events in order for streaming messages */}
94
+ {events.map((event, index) => {
95
+ switch (event.type) {
96
+ case 'text_chunk':
97
+ return (
98
+ <div
99
+ key={`text-${index}`}
100
+ className="text-chunk"
101
+ >
102
+ <Markdown remarkPlugins={[remarkGfm]}>
103
+ {event.content}
104
+ </Markdown>
105
+ </div>
106
+ );
107
+ case 'file_complete':
108
+ return (
109
+ <IndividualFileDiff
110
+ key={`file-${event.fileName}-${index}`}
111
+ fileName={event.fileName}
112
+ beforeContent={
113
+ event.beforeContent || ''
114
+ }
115
+ afterContent={event.afterContent || ''}
116
+ />
117
+ );
118
+ case 'file_start':
119
+ // File start events are now handled by centralized status logic in MessageList
120
+ return null;
121
+ default:
122
+ return null;
123
+ }
124
+ })}
125
+ {/* Additional widgets are now shown within the "Done" status indicator */}
126
+ {showAdditionalWidgets &&
127
+ additionalWidgets &&
128
+ chatId &&
129
+ !isStreaming && // Only show here for non-streaming messages (completed messages)
130
+ additionalWidgets({
131
+ messageId: id,
132
+ chatId: chatId,
133
+ handleSendMessage,
134
+ })}
135
+ {isActive && children}
136
+ </div>
137
+ <div className="ai-chat-message-time">
138
+ {formattedTime}
139
+ </div>
140
+ </div>
141
+ );
142
+ },
143
+ );
144
+
145
+ Message.displayName = 'Message';
@@ -0,0 +1,241 @@
1
+ import {
2
+ useRef,
3
+ useEffect,
4
+ memo,
5
+ useState,
6
+ useContext,
7
+ } from 'react';
8
+ import { Message } from './Message';
9
+ import { TypingIndicator } from './TypingIndicator';
10
+ import { ThinkingScratchpad } from './ThinkingScratchpad';
11
+ import { AIEditingStatusIndicator } from './FileEditingIndicator';
12
+ import { VizChatMessage } from '@vizhub/viz-types';
13
+ import { ExtendedVizChatMessage } from '../../types.js';
14
+ import { DiffViewRef } from './DiffView.js';
15
+ import { VZCodeContext } from '../../client/VZCodeContext';
16
+
17
+ const MessageListComponent = ({
18
+ messages,
19
+ isLoading,
20
+ chatId, // Add chatId prop
21
+ aiScratchpad, // Add aiScratchpad prop
22
+ currentStatus, // Add current status prop
23
+ onNewEvent,
24
+ onJumpToLatest,
25
+ beforeRender,
26
+ afterRender,
27
+ }: {
28
+ messages: VizChatMessage[];
29
+ isLoading: boolean;
30
+ chatId?: string; // Add chatId to the type
31
+ aiScratchpad?: string; // Add aiScratchpad to the type
32
+ currentStatus?: string; // Add current status to the type
33
+ onNewEvent: (targetElement?: HTMLElement) => void;
34
+ onJumpToLatest: (targetElement?: HTMLElement) => void;
35
+ beforeRender: () => number;
36
+ afterRender: (prevScrollHeight: number) => void;
37
+ }) => {
38
+ const messagesEndRef = useRef<HTMLDivElement>(null);
39
+ const diffViewRef = useRef<DiffViewRef>(null);
40
+
41
+ // Get additional widgets from context
42
+ const { additionalWidgets, handleSendMessage } =
43
+ useContext(VZCodeContext);
44
+
45
+ // Track previous loading state to detect when AI generation completes
46
+ const [prevIsLoading, setPrevIsLoading] =
47
+ useState(isLoading);
48
+
49
+ // Auto-scroll when messages change
50
+ useEffect(() => {
51
+ // Get scroll height before render for anchoring
52
+ const prevScrollHeight = beforeRender();
53
+
54
+ // Determine if we should scroll to a specific diff or to the bottom
55
+ const lastMessageHasDiff =
56
+ messages.length > 0 &&
57
+ (messages[messages.length - 1] as any).diffData;
58
+
59
+ let targetElement: HTMLElement | null = null;
60
+ if (lastMessageHasDiff && diffViewRef.current) {
61
+ targetElement =
62
+ diffViewRef.current.getFirstHunkElement();
63
+ }
64
+
65
+ // Trigger auto-scroll if enabled
66
+ onNewEvent(targetElement);
67
+
68
+ // Adjust scroll position after render for anchoring
69
+ afterRender(prevScrollHeight);
70
+ }, [messages, onNewEvent, beforeRender, afterRender]);
71
+
72
+ // Scroll to bottom when AI generation completes (loading changes from true to false)
73
+ useEffect(() => {
74
+ // If AI was generating and now it's finished, force scroll to bottom
75
+ if (prevIsLoading && !isLoading) {
76
+ // Force jump to latest regardless of current auto-scroll state
77
+ onJumpToLatest();
78
+ }
79
+
80
+ setPrevIsLoading(isLoading);
81
+ }, [isLoading, prevIsLoading, onJumpToLatest]);
82
+
83
+ // Check if AI generation has started (last message is from assistant)
84
+ const lastMessage = messages[messages.length - 1];
85
+ const aiGenerationStarted =
86
+ lastMessage?.role === 'assistant' &&
87
+ lastMessage.content !== '';
88
+
89
+ // Show typing indicator only when loading and AI generation hasn't started yet
90
+ const showTypingIndicator =
91
+ isLoading && !aiGenerationStarted;
92
+
93
+ // Show thinking scratchpad when AI is thinking (has scratchpad content)
94
+ const showThinkingScratchpad = Boolean(
95
+ aiScratchpad && aiScratchpad.trim(),
96
+ );
97
+
98
+ // Determine the single, most relevant status to display with priority:
99
+ // 1. Active file editing status from streaming events
100
+ // 2. General AI status from currentStatus
101
+ const getConsolidatedStatus = () => {
102
+ // Check if there's an active streaming message with file editing
103
+ if (lastMessage && lastMessage.role === 'assistant') {
104
+ const extendedMsg =
105
+ lastMessage as ExtendedVizChatMessage;
106
+ if (
107
+ extendedMsg.streamingEvents &&
108
+ extendedMsg.streamingEvents.length > 0
109
+ ) {
110
+ // Look for active file editing (file_start without corresponding file_complete)
111
+ const fileStates = new Map<string, boolean>();
112
+
113
+ extendedMsg.streamingEvents.forEach((event) => {
114
+ if (event.type === 'file_start') {
115
+ fileStates.set(event.fileName, false); // Mark as editing
116
+ } else if (event.type === 'file_complete') {
117
+ fileStates.set(event.fileName, true); // Mark as complete
118
+ }
119
+ });
120
+
121
+ // Find the first file that's still being edited
122
+ for (const [fileName, isComplete] of fileStates) {
123
+ if (!isComplete) {
124
+ return {
125
+ status: `Editing ${fileName}...`,
126
+ fileName,
127
+ };
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Fall back to general AI status if no active file editing
134
+ if (currentStatus) {
135
+ return { status: currentStatus };
136
+ }
137
+
138
+ return null;
139
+ };
140
+
141
+ const consolidatedStatus = getConsolidatedStatus();
142
+
143
+ // Show consolidated status indicator when there's a status to display
144
+ const showConsolidatedStatusIndicator = Boolean(
145
+ consolidatedStatus,
146
+ );
147
+
148
+ // Find the most recent assistant message index
149
+ const lastAssistantMessageIndex = messages
150
+ .map((m, i) => (m.role === 'assistant' ? i : -1))
151
+ .filter((i) => i !== -1)
152
+ .pop();
153
+
154
+ return (
155
+ <div
156
+ className="ai-chat-messages"
157
+ role="log"
158
+ aria-live="polite"
159
+ aria-relevant="additions"
160
+ >
161
+ {messages.map((msg, index) => {
162
+ const isLastMessage = index === messages.length - 1;
163
+ // Cast to extended message to check for streaming events
164
+ const extendedMsg = msg as ExtendedVizChatMessage;
165
+
166
+ // Check if this is a streaming message
167
+ const isStreamingMessage =
168
+ !!extendedMsg.isProgressive;
169
+
170
+ // Only show additionalWidgets for the most recent assistant message
171
+ const showAdditionalWidgets =
172
+ msg.role === 'assistant' &&
173
+ index === lastAssistantMessageIndex;
174
+
175
+ // Use Message for all messages now
176
+ return (
177
+ <Message
178
+ key={msg.id}
179
+ id={msg.id}
180
+ role={msg.role}
181
+ content={
182
+ isStreamingMessage ? undefined : msg.content
183
+ }
184
+ timestamp={msg.timestamp}
185
+ events={
186
+ isStreamingMessage
187
+ ? extendedMsg.streamingEvents || []
188
+ : []
189
+ }
190
+ isActive={index === lastAssistantMessageIndex}
191
+ chatId={chatId}
192
+ showAdditionalWidgets={showAdditionalWidgets}
193
+ isStreaming={isStreamingMessage}
194
+ diffData={(msg as any).diffData}
195
+ ref={
196
+ isLastMessage && (msg as any).diffData
197
+ ? diffViewRef
198
+ : null
199
+ }
200
+ >
201
+ {isStreamingMessage &&
202
+ showThinkingScratchpad && (
203
+ <ThinkingScratchpad
204
+ content={aiScratchpad || ''}
205
+ isVisible={showThinkingScratchpad}
206
+ />
207
+ )}
208
+
209
+ {isStreamingMessage &&
210
+ showConsolidatedStatusIndicator && (
211
+ <AIEditingStatusIndicator
212
+ status={consolidatedStatus?.status || ''}
213
+ fileName={consolidatedStatus?.fileName}
214
+ additionalWidgets={
215
+ consolidatedStatus?.status === 'Done' &&
216
+ showAdditionalWidgets &&
217
+ additionalWidgets &&
218
+ chatId
219
+ ? additionalWidgets({
220
+ messageId: msg.id,
221
+ chatId: chatId,
222
+ handleSendMessage,
223
+ })
224
+ : undefined
225
+ }
226
+ />
227
+ )}
228
+
229
+ {isStreamingMessage && showTypingIndicator && (
230
+ <TypingIndicator />
231
+ )}
232
+ </Message>
233
+ );
234
+ })}
235
+
236
+ <div ref={messagesEndRef} />
237
+ </div>
238
+ );
239
+ };
240
+
241
+ export const MessageList = memo(MessageListComponent);
@@ -0,0 +1,42 @@
1
+ import { memo } from 'react';
2
+ import Markdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+
5
+ interface ThinkingScratchpadProps {
6
+ content: string;
7
+ isVisible: boolean;
8
+ }
9
+
10
+ const ThinkingScratchpadComponent = ({
11
+ content,
12
+ isVisible,
13
+ }: ThinkingScratchpadProps) => {
14
+ if (!isVisible || !content) {
15
+ return null;
16
+ }
17
+
18
+ return (
19
+ <div className="thinking-scratchpad">
20
+ <div className="thinking-scratchpad-header">
21
+ <span className="thinking-scratchpad-icon">🧠</span>
22
+ <span className="thinking-scratchpad-title">
23
+ AI is thinking...
24
+ </span>
25
+ </div>
26
+ <div
27
+ className="thinking-scratchpad-content"
28
+ role="log"
29
+ aria-live="polite"
30
+ aria-relevant="additions"
31
+ >
32
+ <Markdown remarkPlugins={[remarkGfm]}>
33
+ {content}
34
+ </Markdown>
35
+ </div>
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export const ThinkingScratchpad = memo(
41
+ ThinkingScratchpadComponent,
42
+ );
@@ -0,0 +1,19 @@
1
+ import { memo } from 'react';
2
+
3
+ const TypingIndicatorComponent = () => {
4
+ return (
5
+ <div className="ai-chat-message assistant">
6
+ <div className="ai-chat-message-content">
7
+ <div className="ai-chat-typing">
8
+ <span></span>
9
+ <span></span>
10
+ <span></span>
11
+ </div>
12
+ </div>
13
+ </div>
14
+ );
15
+ };
16
+
17
+ export const TypingIndicator = memo(
18
+ TypingIndicatorComponent,
19
+ );