Merge pull request #483 from PuneetP16/feat/enhance-chat-description-management
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:
Anirban Kar 2024-12-04 00:35:09 +05:30 committed by GitHub
commit 7e18820946
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 364 additions and 42 deletions

View File

@ -24,10 +24,11 @@ export function Header() {
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
</a>
</div>
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
<>
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
</span>
{chat.started && (
<ClientOnly>
{() => (
<div className="mr-1">
@ -35,6 +36,7 @@ export function Header() {
</div>
)}
</ClientOnly>
</>
)}
</header>
);

View File

@ -1,6 +1,9 @@
import { useParams } from '@remix-run/react';
import { classNames } from '~/utils/classNames';
import * as Dialog from '@radix-ui/react-dialog';
import { type ChatHistoryItem } from '~/lib/persistence';
import WithTooltip from '~/components/ui/Tooltip';
import { useEditChatDescription } from '~/lib/hooks';
interface HistoryItemProps {
item: ChatHistoryItem;
@ -10,48 +13,115 @@ interface HistoryItemProps {
}
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
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">
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
{item.description}
<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%">
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
<WithTooltip tooltip="Export chat">
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="button"
className="i-ph:download-simple scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
type="submit"
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
onMouseDown={handleSubmit}
/>
</form>
);
return (
<div
className={classNames(
'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',
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
)}
>
{editing ? (
renderDescriptionForm
) : (
<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) => {
event.preventDefault();
exportChat(item.id);
}}
title="Export chat"
/>
</WithTooltip>
{onDuplicate && (
<WithTooltip tooltip="Duplicate chat">
<button
type="button"
className="i-ph:copy scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
<ChatActionButton
toolTipContent="Duplicate chat"
icon="i-ph:copy"
onClick={() => onDuplicate?.(item.id)}
title="Duplicate chat"
/>
</WithTooltip>
)}
<ChatActionButton
toolTipContent="Rename chat"
icon="i-ph:pencil-fill"
onClick={(event) => {
event.preventDefault();
toggleEditMode();
}}
/>
<Dialog.Trigger asChild>
<WithTooltip tooltip="Delete chat">
<button
type="button"
className="i-ph:trash scale-110 hover:text-bolt-elements-button-danger-text"
<ChatActionButton
toolTipContent="Delete chat"
icon="i-ph:trash"
className="[&&]:hover:text-bolt-elements-button-danger-text"
onClick={(event) => {
event.preventDefault();
onDelete?.(event);
}}
/>
</WithTooltip>
</Dialog.Trigger>
</div>
</div>
</a>
)}
</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>
);
};

View File

@ -2,4 +2,5 @@ export * from './useMessageParser';
export * from './usePromptEnhancer';
export * from './useShortcuts';
export * from './useSnapScroll';
export * from './useEditChatDescription';
export { default } from './useViewport';

View 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,
};
}

View File

@ -1,6 +1,68 @@
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() {
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>
);
}

View File

@ -52,17 +52,23 @@ export async function setMessages(
messages: Message[],
urlId?: string,
description?: string,
timestamp?: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readwrite');
const store = transaction.objectStore('chats');
if (timestamp && isNaN(Date.parse(timestamp))) {
reject(new Error('Invalid timestamp'));
return;
}
const request = store.put({
id,
messages,
urlId,
description,
timestamp: new Date().toISOString(),
timestamp: timestamp ?? new Date().toISOString(),
});
request.onsuccess = () => resolve();
@ -212,3 +218,17 @@ export async function createChatFromMessages(
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);
}

View File

@ -100,6 +100,10 @@ export class ActionRunner {
.catch((error) => {
console.error('Action failed:', error);
});
await this.#currentExecutionPromise;
return;
}
async #executeAction(actionId: string, isStreaming: boolean = false) {