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.
- package/README.md +359 -0
- package/dist/assets/index-CfDs-dAu.js +476 -0
- package/dist/assets/{index-BvGPtSrr.css → index-fSroLVgi.css} +1 -1
- package/dist/assets/{worker-y5jhqCTR.js → worker-Cm3I3tnR.js} +57 -57
- package/dist/assets/{worker-ClF0pBYr.js → worker-KiuLM-X4.js} +71 -71
- package/dist/index.html +2 -2
- package/dist/llm-streaming-server/aiEditing.js +58 -0
- package/dist/llm-streaming-server/chatOperations.js +494 -0
- package/dist/llm-streaming-server/errorHandling.js +49 -0
- package/dist/llm-streaming-server/index.js +20 -0
- package/dist/llm-streaming-server/llmStreaming.js +265 -0
- package/dist/llm-streaming-server/validation.js +19 -0
- package/dist/server/aiChatHandler/chatOperations.js +3 -4
- package/dist/server/aiChatHandler/index.js +5 -5
- package/dist/server/aiChatHandler/llmStreaming.js +57 -4
- package/dist/server/prettier.js +18 -16
- package/dist/utils/fileDiff.js +25 -1
- package/package.json +45 -45
- package/src/client/CodeEditor/getOrCreateEditor.tsx +272 -258
- package/src/client/CodeEditor/index.tsx +3 -3
- package/src/client/VZRight.tsx +4 -0
- package/src/client/VZSidebar/AIChat/ChatInput.tsx +1 -1
- package/src/client/VZSidebar/AIChat/DiffView.scss +37 -1
- package/src/client/VZSidebar/AIChat/DiffView.tsx +55 -16
- package/src/client/VZSidebar/AIChat/styles.scss +38 -0
- package/src/client/VZSidebar/FileTypeIcon.tsx +1 -0
- package/src/client/VZSidebar/index.tsx +1 -1
- package/src/client/featureFlags.ts +7 -0
- package/src/llm-streaming-server/README.md +71 -0
- package/src/llm-streaming-server/aiEditing.ts +102 -0
- package/src/llm-streaming-server/chatOperations.ts +655 -0
- package/src/llm-streaming-server/errorHandling.ts +66 -0
- package/src/llm-streaming-server/index.ts +51 -0
- package/src/llm-streaming-server/llmStreaming.ts +403 -0
- package/src/llm-streaming-server/validation.ts +24 -0
- package/src/llm-streaming-ui/README.md +103 -0
- package/src/llm-streaming-ui/components/ChatInput.tsx +237 -0
- package/src/llm-streaming-ui/components/DiffView.scss +173 -0
- package/src/llm-streaming-ui/components/DiffView.tsx +236 -0
- package/src/llm-streaming-ui/components/FileEditingIndicator.tsx +89 -0
- package/src/llm-streaming-ui/components/IndividualFileDiff.tsx +86 -0
- package/src/llm-streaming-ui/components/JumpToLatestButton.tsx +64 -0
- package/src/llm-streaming-ui/components/Message.tsx +145 -0
- package/src/llm-streaming-ui/components/MessageList.tsx +241 -0
- package/src/llm-streaming-ui/components/ThinkingScratchpad.tsx +42 -0
- package/src/llm-streaming-ui/components/TypingIndicator.tsx +19 -0
- package/src/llm-streaming-ui/components/index.tsx +388 -0
- package/src/llm-streaming-ui/components/styles.scss +831 -0
- package/src/llm-streaming-ui/components/useSpeechRecognition.ts +116 -0
- package/src/llm-streaming-ui/index.ts +34 -0
- package/src/server/aiChatHandler/chatOperations.ts +3 -4
- package/src/server/aiChatHandler/index.ts +5 -5
- package/src/server/aiChatHandler/llmStreaming.ts +87 -4
- package/src/server/prettier.ts +30 -31
- package/src/types.ts +5 -0
- package/src/utils/fileDiff.ts +36 -2
- 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
|
+
);
|