Assorted fixes around approve changes UI (#45)

This commit is contained in:
Brian Hackett
2025-03-04 07:22:42 -08:00
committed by GitHub
parent 1e7af9f5c8
commit 23fa6f2217
6 changed files with 237 additions and 213 deletions

View File

@@ -9,17 +9,19 @@ export interface RejectChangeData {
}
interface ApproveChangeProps {
rejectFormOpen: boolean;
setRejectFormOpen: (rejectFormOpen: boolean) => void;
onApprove: () => void;
onReject: (data: RejectChangeData) => void;
}
const ApproveChange: React.FC<ApproveChangeProps> = ({ onApprove, onReject }) => {
const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setRejectFormOpen, onApprove, onReject }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [hasRejected, setHasRejected] = useState(false);
const [shareProject, setShareProject] = useState(false);
if (hasRejected) {
if (rejectFormOpen) {
const performReject = (retry: boolean) => {
setRejectFormOpen(false);
const explanation = textareaRef.current?.value ?? '';
onReject({
explanation,
@@ -32,7 +34,7 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ onApprove, onReject }) =>
<>
<div
className={classNames(
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg bg-red-50 mt-3',
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg bg-red-50 mb-2',
)}
>
<textarea
@@ -78,16 +80,16 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ onApprove, onReject }) =>
<button
onClick={() => performReject(false)}
className="flex-1 h-[30px] flex justify-center items-center bg-red-100 border border-red-500 text-red-500 hover:bg-red-200 hover:text-red-600 transition-colors rounded"
aria-label="Cancel changes"
title="Cancel changes"
aria-label="Revert changes"
title="Revert changes"
>
<div className="i-ph:x-bold"></div>
<div className="i-ph:arrow-arc-left-bold"></div>
</button>
<button
onClick={() => performReject(true)}
className="flex-1 h-[30px] flex justify-center items-center bg-green-100 border border-green-500 text-green-500 hover:bg-green-200 hover:text-green-600 transition-colors rounded"
aria-label="Try changes again"
title="Try changes again"
aria-label="Retry changes"
title="Retry changes"
>
<div className="i-ph:repeat-bold"></div>
</button>
@@ -97,9 +99,9 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ onApprove, onReject }) =>
}
return (
<div className="flex items-center gap-1 w-full h-[30px] pt-2">
<div className="flex items-center gap-1 w-full h-[30px] mb-2">
<button
onClick={() => setHasRejected(true)}
onClick={() => setRejectFormOpen(true)}
className="flex-1 h-[30px] flex justify-center items-center bg-red-100 border border-red-500 text-red-500 hover:bg-red-200 hover:text-red-600 transition-colors rounded"
aria-label="Reject change"
title="Reject change"

View File

@@ -9,7 +9,7 @@ import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames';
import { Messages } from './Messages.client';
import { getLastMessageProjectContents, hasFileModifications, Messages } from './Messages.client';
import { SendButton } from './SendButton.client';
import { APIKeyManager } from './APIKeyManager';
import Cookies from 'js-cookie';
@@ -27,6 +27,8 @@ import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify';
import type { RejectChangeData } from './ApproveChange';
import { assert } from '~/lib/replay/ReplayProtocolClient';
import ApproveChange from './ApproveChange';
export const TEXTAREA_MIN_HEIGHT = 76;
@@ -93,6 +95,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const [transcript, setTranscript] = useState('');
const [rejectFormOpen, setRejectFormOpen] = useState(false);
useEffect(() => {
console.log(transcript);
@@ -218,6 +221,149 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}
};
const showApproveChange = (() => {
if (isStreaming) {
return false;
}
if (!messages?.length) {
return false;
}
const lastMessageProjectContents = getLastMessageProjectContents(messages, messages.length - 1);
if (!lastMessageProjectContents) {
return false;
}
if (lastMessageProjectContents.contentsMessageId != approveChangesMessageId) {
return false;
}
const lastMessage = messages[messages.length - 1];
if (!hasFileModifications(lastMessage.content)) {
return false;
}
return true;
})();
let messageInput;
if (!rejectFormOpen) {
messageInput = (
<div
className={classNames(
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
)}
>
<textarea
ref={textareaRef}
className={classNames(
'w-full pl-4 pt-4 pr-25 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
'transition-all duration-200',
'hover:border-bolt-elements-focus',
)}
onDragEnter={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragLeave={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const base64Image = e.target?.result as string;
setUploadedFiles?.([...uploadedFiles, file]);
setImageDataList?.([...imageDataList, base64Image]);
};
reader.readAsDataURL(file);
}
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
if (isStreaming) {
handleStop?.();
return;
}
// ignore if using input method engine
if (event.nativeEvent.isComposing) {
return;
}
handleSendMessage?.(event);
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
onPaste={handlePaste}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder={chatStarted ? "How can we help you?" : "What do you want to build?"}
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={(input.length > 0 || uploadedFiles.length > 0) && chatStarted}
isStreaming={isStreaming}
onClick={(event) => {
if (input.length > 0 || uploadedFiles.length > 0) {
handleSendMessage?.(event);
}
}}
/>
)}
</ClientOnly>
<div className="flex justify-between items-center text-sm p-4 pt-2">
<div className="flex gap-1 items-center">
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
<div className="i-ph:paperclip text-xl"></div>
</IconButton>
<SpeechRecognitionButton
isListening={isListening}
onStart={startListening}
onStop={stopListening}
disabled={isStreaming}
/>
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a
new line
</div>
) : null}
</div>
</div>
);
}
const baseChat = (
<div
ref={ref}
@@ -251,9 +397,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages={messages}
isStreaming={isStreaming}
onRewind={onRewind}
approveChangesMessageId={approveChangesMessageId}
onApproveChange={onApproveChange}
onRejectChange={onRejectChange}
/>
) : null;
}}
@@ -310,117 +453,31 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
/>
)}
</ClientOnly>
<div
className={classNames(
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
)}
>
<textarea
ref={textareaRef}
className={classNames(
'w-full pl-4 pt-4 pr-25 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
'transition-all duration-200',
'hover:border-bolt-elements-focus',
)}
onDragEnter={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragLeave={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const base64Image = e.target?.result as string;
setUploadedFiles?.([...uploadedFiles, file]);
setImageDataList?.([...imageDataList, base64Image]);
};
reader.readAsDataURL(file);
}
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
if (isStreaming) {
handleStop?.();
return;
}
// ignore if using input method engine
if (event.nativeEvent.isComposing) {
return;
}
handleSendMessage?.(event);
{showApproveChange && (
<ApproveChange
rejectFormOpen={rejectFormOpen}
setRejectFormOpen={setRejectFormOpen}
onApprove={() => {
if (onApproveChange && messages) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
onApproveChange(lastMessage.id);
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
onPaste={handlePaste}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder={chatStarted ? "How can we help you?" : "What do you want to build?"}
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={(input.length > 0 || uploadedFiles.length > 0) && chatStarted}
isStreaming={isStreaming}
onClick={(event) => {
if (input.length > 0 || uploadedFiles.length > 0) {
handleSendMessage?.(event);
}
}}
/>
)}
</ClientOnly>
<div className="flex justify-between items-center text-sm p-4 pt-2">
<div className="flex gap-1 items-center">
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
<div className="i-ph:paperclip text-xl"></div>
</IconButton>
onReject={(data) => {
if (onRejectChange && messages) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
<SpeechRecognitionButton
isListening={isListening}
onStart={startListening}
onStop={stopListening}
disabled={isStreaming}
/>
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a
new line
</div>
) : null}
</div>
</div>
const info = getLastMessageProjectContents(messages, messages.length - 1);
assert(info);
onRejectChange(lastMessage.id, info.rewindMessageId, info.contents.content, data);
}
}}
/>
)}
{!rejectFormOpen && messageInput}
</div>
</div>
{!chatStarted && (

View File

@@ -323,7 +323,7 @@ export const ChatImpl = memo(
}
const enhancedPromptMessage: Message = {
id: `enhanced-prompt-${messages.length}`,
id: `enhanced-prompt-${Math.random()}`,
role: 'assistant',
content: message,
};

View File

@@ -1,14 +1,10 @@
import type { Message } from 'ai';
import React, { Suspense, useState } from 'react';
import React, { Suspense } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage, getAnnotationsTokensUsage } from './AssistantMessage';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
import { useLocation } from '@remix-run/react';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';
import WithTooltip from '~/components/ui/Tooltip';
import { assert, sendCommandDedicatedClient } from '~/lib/replay/ReplayProtocolClient';
import ApproveChange, { type RejectChangeData } from './ApproveChange';
import { assert } from '~/lib/replay/ReplayProtocolClient';
interface MessagesProps {
id?: string;
@@ -16,9 +12,6 @@ interface MessagesProps {
isStreaming?: boolean;
messages?: Message[];
onRewind?: (messageId: string, contents: string) => void;
approveChangesMessageId?: string;
onApproveChange?: (messageId: string) => void;
onRejectChange?: (lastMessageId: string, rewindMessageId: string, contents: string, data: RejectChangeData) => void;
}
interface ProjectContents {
@@ -31,55 +24,33 @@ export function saveProjectContents(messageId: string, contents: ProjectContents
gProjectContentsByMessageId.set(messageId, contents);
}
function hasFileModifications(content: string) {
export function getLastMessageProjectContents(messages: Message[], index: number) {
// The message index is for the model response, and the project
// contents will be associated with the last message present when
// the user prompt was sent to the model. This could be either two
// or three messages back, depending on whether a bug explanation was added.
const beforeUserMessage = messages[index - 2];
const contents = gProjectContentsByMessageId.get(beforeUserMessage?.id);
if (!contents) {
const priorMessage = messages[index - 3];
const priorContents = gProjectContentsByMessageId.get(priorMessage?.id);
if (!priorContents) {
return undefined;
}
// We still rewind to just before the user message to retain any
// explanation from the Nut API.
return { rewindMessageId: beforeUserMessage.id, contentsMessageId: priorMessage.id, contents: priorContents };
}
return { rewindMessageId: beforeUserMessage.id, contentsMessageId: beforeUserMessage.id, contents };
}
export function hasFileModifications(content: string) {
return content.includes('__boltArtifact__');
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [], onRewind, approveChangesMessageId, onApproveChange, onRejectChange } = props;
const getLastMessageProjectContents = (index: number) => {
// The message index is for the model response, and the project
// contents will be associated with the last message present when
// the user prompt was sent to the model. This could be either two
// or three messages back, depending on whether a bug explanation was added.
const beforeUserMessage = messages[index - 2];
const contents = gProjectContentsByMessageId.get(beforeUserMessage?.id);
if (!contents) {
const priorMessage = messages[index - 3];
const priorContents = gProjectContentsByMessageId.get(priorMessage?.id);
if (!priorContents) {
return undefined;
}
// We still rewind to just before the user message to retain any
// explanation from the Nut API.
return { messageId: beforeUserMessage.id, contents: priorContents };
}
return { messageId: beforeUserMessage.id, contents };
};
const showApproveChange = (() => {
if (isStreaming) {
return false;
}
if (!messages.length) {
return false;
}
const lastMessageProjectContents = getLastMessageProjectContents(messages.length - 1);
if (!lastMessageProjectContents) {
return false;
}
if (lastMessageProjectContents.messageId != approveChangesMessageId) {
return false;
}
const lastMessage = messages[messages.length - 1];
return hasFileModifications(lastMessage.content);
})();
const { id, isStreaming = false, messages = [], onRewind } = props;
return (
<div id={id} ref={ref} className={props.className}>
@@ -121,15 +92,15 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
{!isUserMessage &&
messageId &&
onRewind &&
getLastMessageProjectContents(index) &&
getLastMessageProjectContents(messages, index) &&
hasFileModifications(content) && (
<div className="flex gap-2 flex-col lg:flex-row">
<WithTooltip tooltip="Undo changes in this message">
<button
onClick={() => {
const info = getLastMessageProjectContents(index);
const info = getLastMessageProjectContents(messages, index);
assert(info);
onRewind(info.messageId, info.contents.content);
onRewind(info.rewindMessageId, info.contents.content);
}}
key="i-ph:arrow-u-up-left"
className={classNames(
@@ -148,28 +119,6 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
{showApproveChange && (
<ApproveChange
onApprove={() => {
if (onApproveChange) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
onApproveChange(lastMessage.id);
}
}}
onReject={(data) => {
if (onRejectChange) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
const info = getLastMessageProjectContents(messages.length - 1);
assert(info);
onRejectChange(lastMessage.id, info.messageId, info.contents.content, data);
}
}}
/>
)}
</div>
);
});

View File

@@ -109,7 +109,7 @@ export async function submitProblem(problem: BoltProblemInput): Promise<string |
}
}
export async function updateProblem(problemId: string, problem: BoltProblemInput) {
export async function updateProblem(problemId: string, problem: BoltProblemInput | undefined) {
try {
if (!getNutIsAdmin()) {
toast.error("Admin user required");

View File

@@ -12,16 +12,16 @@ import type { BoltProblem, BoltProblemComment } from '~/lib/replay/Problems';
function Comments({ comments }: { comments: BoltProblemComment[] }) {
return (
<div className="comments">
<div className="space-y-4 mt-6">
{comments.map((comment, index) => (
<div key={index} className="comment">
<div className="comment-header">
<span className="comment-username">{comment.username ?? ""}</span>
<span className="comment-date">
<div key={index} className="bg-bolt-elements-background-depth-2 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-bolt-text">{comment.username ?? "Anonymous"}</span>
<span className="text-sm text-bolt-text-secondary">
{new Date(comment.timestamp).toLocaleString()}
</span>
</div>
<div className="comment-text">{comment.content}</div>
<div className="text-bolt-text whitespace-pre-wrap">{comment.content}</div>
</div>
))}
</div>
@@ -89,8 +89,9 @@ function UpdateProblemForm(props: UpdateProblemFormProps) {
type DoUpdateCallback = (problem: BoltProblem) => BoltProblem;
type UpdateProblemCallback = (doUpdate: DoUpdateCallback) => void;
type DeleteProblemCallback = () => void;
function UpdateProblemForms({ updateProblem }: { updateProblem: UpdateProblemCallback }) {
function UpdateProblemForms({ updateProblem, deleteProblem }: { updateProblem: UpdateProblemCallback, deleteProblem: DeleteProblemCallback }) {
const handleAddComment = (content: string) => {
const newComment: BoltProblemComment = {
timestamp: Date.now(),
@@ -147,6 +148,15 @@ function UpdateProblemForms({ updateProblem }: { updateProblem: UpdateProblemCal
<UpdateProblemForm handleSubmit={handleSetDescription} updateText="Set Description" placeholder="Set the description of the problem..." />
<UpdateProblemForm handleSubmit={handleSetStatus} updateText="Set Status" placeholder="Set the status of the problem..." />
<UpdateProblemForm handleSubmit={handleSetKeywords} updateText="Set Keywords" placeholder="Set the keywords of the problem..." />
<div className="mb-6 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<button
onClick={deleteProblem}
className="px-6 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors duration-200 font-medium"
>
Delete Problem
</button>
</div>
</>
)
}
@@ -173,6 +183,12 @@ function ViewProblemPage() {
await backendUpdateProblem(problemId, newProblem);
}, [problemData]);
const deleteProblem = useCallback(async () => {
console.log("BackendDeleteProblem", problemId);
await backendUpdateProblem(problemId, undefined);
toast.success("Problem deleted");
}, [problemData]);
useEffect(() => {
getProblem(problemId).then(setProblemData);
}, [problemId]);
@@ -193,7 +209,7 @@ function ViewProblemPage() {
: <ProblemViewer problem={problemData} />}
</div>
{getNutIsAdmin() && problemData && (
<UpdateProblemForms updateProblem={updateProblem} />
<UpdateProblemForms updateProblem={updateProblem} deleteProblem={deleteProblem} />
)}
<ToastContainerWrapper />
</div>