mirror of
https://github.com/coleam00/bolt.new-any-llm
synced 2024-12-27 22:33:03 +00:00
Merge pull request #483 from PuneetP16/feat/enhance-chat-description-management
Some checks are pending
Docs CI/CD / build_docs (push) Waiting to run
Some checks are pending
Docs CI/CD / build_docs (push) Waiting to run
[feat]: Implement chat description editing in sidebar and header, add…
This commit is contained in:
commit
7e18820946
@ -24,17 +24,19 @@ export function Header() {
|
|||||||
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
<>
|
||||||
</span>
|
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||||
{chat.started && (
|
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||||
<ClientOnly>
|
</span>
|
||||||
{() => (
|
<ClientOnly>
|
||||||
<div className="mr-1">
|
{() => (
|
||||||
<HeaderActionButtons />
|
<div className="mr-1">
|
||||||
</div>
|
<HeaderActionButtons />
|
||||||
)}
|
</div>
|
||||||
</ClientOnly>
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
|
import { useParams } from '@remix-run/react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||||
import WithTooltip from '~/components/ui/Tooltip';
|
import WithTooltip from '~/components/ui/Tooltip';
|
||||||
|
import { useEditChatDescription } from '~/lib/hooks';
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
item: ChatHistoryItem;
|
item: ChatHistoryItem;
|
||||||
@ -10,48 +13,115 @@ interface HistoryItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
||||||
|
const { id: urlId } = useParams();
|
||||||
|
const isActiveChat = urlId === item.urlId;
|
||||||
|
|
||||||
|
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||||
|
useEditChatDescription({
|
||||||
|
initialDescription: item.description,
|
||||||
|
customChatId: item.id,
|
||||||
|
syncWithGlobalStore: isActiveChat,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderDescriptionForm = (
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 flex items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
|
||||||
|
autoFocus
|
||||||
|
value={currentDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||||
|
onMouseDown={handleSubmit}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1">
|
<div
|
||||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
className={classNames(
|
||||||
{item.description}
|
'group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1',
|
||||||
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-99%">
|
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
|
||||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
|
)}
|
||||||
<WithTooltip tooltip="Export chat">
|
>
|
||||||
<button
|
{editing ? (
|
||||||
type="button"
|
renderDescriptionForm
|
||||||
className="i-ph:download-simple scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
|
) : (
|
||||||
|
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||||
|
{currentDescription}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-22 group-hover:from-99%',
|
||||||
|
{ 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<ChatActionButton
|
||||||
|
toolTipContent="Export chat"
|
||||||
|
icon="i-ph:download-simple"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
exportChat(item.id);
|
exportChat(item.id);
|
||||||
}}
|
}}
|
||||||
title="Export chat"
|
|
||||||
/>
|
/>
|
||||||
</WithTooltip>
|
{onDuplicate && (
|
||||||
{onDuplicate && (
|
<ChatActionButton
|
||||||
<WithTooltip tooltip="Duplicate chat">
|
toolTipContent="Duplicate chat"
|
||||||
<button
|
icon="i-ph:copy"
|
||||||
type="button"
|
|
||||||
className="i-ph:copy scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
|
|
||||||
onClick={() => onDuplicate?.(item.id)}
|
onClick={() => onDuplicate?.(item.id)}
|
||||||
title="Duplicate chat"
|
|
||||||
/>
|
/>
|
||||||
</WithTooltip>
|
)}
|
||||||
)}
|
<ChatActionButton
|
||||||
<Dialog.Trigger asChild>
|
toolTipContent="Rename chat"
|
||||||
<WithTooltip tooltip="Delete chat">
|
icon="i-ph:pencil-fill"
|
||||||
<button
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault();
|
||||||
className="i-ph:trash scale-110 hover:text-bolt-elements-button-danger-text"
|
toggleEditMode();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<ChatActionButton
|
||||||
|
toolTipContent="Delete chat"
|
||||||
|
icon="i-ph:trash"
|
||||||
|
className="[&&]:hover:text-bolt-elements-button-danger-text"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onDelete?.(event);
|
onDelete?.(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WithTooltip>
|
</Dialog.Trigger>
|
||||||
</Dialog.Trigger>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ChatActionButton = ({
|
||||||
|
toolTipContent,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
toolTipContent: string;
|
||||||
|
icon: string;
|
||||||
|
className?: string;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
btnTitle?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<WithTooltip tooltip={toolTipContent}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</WithTooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -2,4 +2,5 @@ export * from './useMessageParser';
|
|||||||
export * from './usePromptEnhancer';
|
export * from './usePromptEnhancer';
|
||||||
export * from './useShortcuts';
|
export * from './useShortcuts';
|
||||||
export * from './useSnapScroll';
|
export * from './useSnapScroll';
|
||||||
|
export * from './useEditChatDescription';
|
||||||
export { default } from './useViewport';
|
export { default } from './useViewport';
|
||||||
|
163
app/lib/hooks/useEditChatDescription.ts
Normal file
163
app/lib/hooks/useEditChatDescription.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import {
|
||||||
|
chatId as chatIdStore,
|
||||||
|
description as descriptionStore,
|
||||||
|
db,
|
||||||
|
updateChatDescription,
|
||||||
|
getMessages,
|
||||||
|
} from '~/lib/persistence';
|
||||||
|
|
||||||
|
interface EditChatDescriptionOptions {
|
||||||
|
initialDescription?: string;
|
||||||
|
customChatId?: string;
|
||||||
|
syncWithGlobalStore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditChatDescriptionHook = {
|
||||||
|
editing: boolean;
|
||||||
|
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleBlur: () => Promise<void>;
|
||||||
|
handleSubmit: (event: React.FormEvent) => Promise<void>;
|
||||||
|
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
currentDescription: string;
|
||||||
|
toggleEditMode: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage the state and behavior for editing chat descriptions.
|
||||||
|
*
|
||||||
|
* Offers functions to:
|
||||||
|
* - Switch between edit and view modes.
|
||||||
|
* - Manage input changes, blur, and form submission events.
|
||||||
|
* - Save updates to IndexedDB and optionally to the global application state.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.initialDescription - The current chat description.
|
||||||
|
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
|
||||||
|
* @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
|
||||||
|
* @returns {EditChatDescriptionHook} Methods and state for managing description edits.
|
||||||
|
*/
|
||||||
|
export function useEditChatDescription({
|
||||||
|
initialDescription = descriptionStore.get()!,
|
||||||
|
customChatId,
|
||||||
|
syncWithGlobalStore,
|
||||||
|
}: EditChatDescriptionOptions): EditChatDescriptionHook {
|
||||||
|
const chatIdFromStore = useStore(chatIdStore);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [currentDescription, setCurrentDescription] = useState(initialDescription);
|
||||||
|
|
||||||
|
const [chatId, setChatId] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChatId(customChatId || chatIdFromStore);
|
||||||
|
}, [customChatId, chatIdFromStore]);
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDescription(initialDescription);
|
||||||
|
}, [initialDescription]);
|
||||||
|
|
||||||
|
const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCurrentDescription(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLatestDescription = useCallback(async () => {
|
||||||
|
if (!db || !chatId) {
|
||||||
|
return initialDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chat = await getMessages(db, chatId);
|
||||||
|
return chat?.description || initialDescription;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch latest description:', error);
|
||||||
|
return initialDescription;
|
||||||
|
}
|
||||||
|
}, [db, chatId, initialDescription]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(async () => {
|
||||||
|
const latestDescription = await fetchLatestDescription();
|
||||||
|
setCurrentDescription(latestDescription);
|
||||||
|
toggleEditMode();
|
||||||
|
}, [fetchLatestDescription, toggleEditMode]);
|
||||||
|
|
||||||
|
const isValidDescription = useCallback((desc: string): boolean => {
|
||||||
|
const trimmedDesc = desc.trim();
|
||||||
|
|
||||||
|
if (trimmedDesc === initialDescription) {
|
||||||
|
toggleEditMode();
|
||||||
|
return false; // No change, skip validation
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
|
||||||
|
const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
|
||||||
|
|
||||||
|
if (!lengthValid) {
|
||||||
|
toast.error('Description must be between 1 and 100 characters.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!characterValid) {
|
||||||
|
toast.error('Description can only contain alphanumeric characters and spaces.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!isValidDescription(currentDescription)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!db) {
|
||||||
|
toast.error('Chat persistence is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chatId) {
|
||||||
|
toast.error('Chat Id is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateChatDescription(db, chatId, currentDescription);
|
||||||
|
|
||||||
|
if (syncWithGlobalStore) {
|
||||||
|
descriptionStore.set(currentDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Chat description updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update chat description: ' + (error as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEditMode();
|
||||||
|
},
|
||||||
|
[currentDescription, db, chatId, initialDescription, customChatId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
await handleBlur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleBlur],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
handleChange,
|
||||||
|
handleBlur,
|
||||||
|
handleSubmit,
|
||||||
|
handleKeyDown,
|
||||||
|
currentDescription,
|
||||||
|
toggleEditMode,
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,68 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { description } from './useChatHistory';
|
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||||
|
import WithTooltip from '~/components/ui/Tooltip';
|
||||||
|
import { useEditChatDescription } from '~/lib/hooks';
|
||||||
|
import { description as descriptionStore } from '~/lib/persistence';
|
||||||
|
|
||||||
export function ChatDescription() {
|
export function ChatDescription() {
|
||||||
return useStore(description);
|
const initialDescription = useStore(descriptionStore)!;
|
||||||
|
|
||||||
|
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||||
|
useEditChatDescription({
|
||||||
|
initialDescription,
|
||||||
|
syncWithGlobalStore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initialDescription) {
|
||||||
|
// doing this to prevent showing edit button until chat description is set
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2 w-fit"
|
||||||
|
autoFocus
|
||||||
|
value={currentDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{ width: `${Math.max(currentDescription.length * 8, 100)}px` }}
|
||||||
|
/>
|
||||||
|
<TooltipProvider>
|
||||||
|
<WithTooltip tooltip="Save title">
|
||||||
|
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="i-ph:check-bold scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||||
|
onMouseDown={handleSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</WithTooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{currentDescription}
|
||||||
|
<TooltipProvider>
|
||||||
|
<WithTooltip tooltip="Rename chat">
|
||||||
|
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent ml-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleEditMode();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</WithTooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -52,17 +52,23 @@ export async function setMessages(
|
|||||||
messages: Message[],
|
messages: Message[],
|
||||||
urlId?: string,
|
urlId?: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
|
timestamp?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction('chats', 'readwrite');
|
const transaction = db.transaction('chats', 'readwrite');
|
||||||
const store = transaction.objectStore('chats');
|
const store = transaction.objectStore('chats');
|
||||||
|
|
||||||
|
if (timestamp && isNaN(Date.parse(timestamp))) {
|
||||||
|
reject(new Error('Invalid timestamp'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const request = store.put({
|
const request = store.put({
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
urlId,
|
urlId,
|
||||||
description,
|
description,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: timestamp ?? new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
request.onsuccess = () => resolve();
|
request.onsuccess = () => resolve();
|
||||||
@ -212,3 +218,17 @@ export async function createChatFromMessages(
|
|||||||
|
|
||||||
return newUrlId; // Return the urlId instead of id for navigation
|
return newUrlId; // Return the urlId instead of id for navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateChatDescription(db: IDBDatabase, id: string, description: string): Promise<void> {
|
||||||
|
const chat = await getMessages(db, id);
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
throw new Error('Chat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description.trim()) {
|
||||||
|
throw new Error('Description cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setMessages(db, id, chat.messages, chat.urlId, description, chat.timestamp);
|
||||||
|
}
|
||||||
|
@ -100,6 +100,10 @@ export class ActionRunner {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Action failed:', error);
|
console.error('Action failed:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.#currentExecutionPromise;
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #executeAction(actionId: string, isStreaming: boolean = false) {
|
async #executeAction(actionId: string, isStreaming: boolean = false) {
|
||||||
|
Loading…
Reference in New Issue
Block a user