codacus commited on
Commit
7e18820
·
unverified ·
2 Parent(s): ae8ee37 b9575c8

Merge pull request #483 from PuneetP16/feat/enhance-chat-description-management

Browse files
app/components/header/Header.tsx CHANGED
@@ -24,17 +24,19 @@ export function Header() {
24
  <span className="i-bolt:logo-text?mask w-[46px] inline-block" />
25
  </a>
26
  </div>
27
- <span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
28
- <ClientOnly>{() => <ChatDescription />}</ClientOnly>
29
- </span>
30
- {chat.started && (
31
- <ClientOnly>
32
- {() => (
33
- <div className="mr-1">
34
- <HeaderActionButtons />
35
- </div>
36
- )}
37
- </ClientOnly>
 
 
38
  )}
39
  </header>
40
  );
 
24
  <span className="i-bolt:logo-text?mask w-[46px] inline-block" />
25
  </a>
26
  </div>
27
+ {chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
28
+ <>
29
+ <span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
30
+ <ClientOnly>{() => <ChatDescription />}</ClientOnly>
31
+ </span>
32
+ <ClientOnly>
33
+ {() => (
34
+ <div className="mr-1">
35
+ <HeaderActionButtons />
36
+ </div>
37
+ )}
38
+ </ClientOnly>
39
+ </>
40
  )}
41
  </header>
42
  );
app/components/sidebar/HistoryItem.tsx CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import * as Dialog from '@radix-ui/react-dialog';
2
  import { type ChatHistoryItem } from '~/lib/persistence';
3
  import WithTooltip from '~/components/ui/Tooltip';
 
4
 
5
  interface HistoryItemProps {
6
  item: ChatHistoryItem;
@@ -10,48 +13,115 @@ interface HistoryItemProps {
10
  }
11
 
12
  export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  return (
14
- <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">
15
- <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
16
- {item.description}
17
- <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%">
18
- <div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
19
- <WithTooltip tooltip="Export chat">
20
- <button
21
- type="button"
22
- className="i-ph:download-simple scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
 
 
 
 
 
 
 
 
 
 
 
 
23
  onClick={(event) => {
24
  event.preventDefault();
25
  exportChat(item.id);
26
  }}
27
- title="Export chat"
28
  />
29
- </WithTooltip>
30
- {onDuplicate && (
31
- <WithTooltip tooltip="Duplicate chat">
32
- <button
33
- type="button"
34
- className="i-ph:copy scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
35
  onClick={() => onDuplicate?.(item.id)}
36
- title="Duplicate chat"
37
  />
38
- </WithTooltip>
39
- )}
40
- <Dialog.Trigger asChild>
41
- <WithTooltip tooltip="Delete chat">
42
- <button
43
- type="button"
44
- className="i-ph:trash scale-110 hover:text-bolt-elements-button-danger-text"
 
 
 
 
 
 
 
45
  onClick={(event) => {
46
  event.preventDefault();
47
  onDelete?.(event);
48
  }}
49
  />
50
- </WithTooltip>
51
- </Dialog.Trigger>
52
  </div>
53
- </div>
54
- </a>
55
  </div>
56
  );
57
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useParams } from '@remix-run/react';
2
+ import { classNames } from '~/utils/classNames';
3
  import * as Dialog from '@radix-ui/react-dialog';
4
  import { type ChatHistoryItem } from '~/lib/persistence';
5
  import WithTooltip from '~/components/ui/Tooltip';
6
+ import { useEditChatDescription } from '~/lib/hooks';
7
 
8
  interface HistoryItemProps {
9
  item: ChatHistoryItem;
 
13
  }
14
 
15
  export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
16
+ const { id: urlId } = useParams();
17
+ const isActiveChat = urlId === item.urlId;
18
+
19
+ const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
20
+ useEditChatDescription({
21
+ initialDescription: item.description,
22
+ customChatId: item.id,
23
+ syncWithGlobalStore: isActiveChat,
24
+ });
25
+
26
+ const renderDescriptionForm = (
27
+ <form onSubmit={handleSubmit} className="flex-1 flex items-center">
28
+ <input
29
+ type="text"
30
+ className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
31
+ autoFocus
32
+ value={currentDescription}
33
+ onChange={handleChange}
34
+ onBlur={handleBlur}
35
+ onKeyDown={handleKeyDown}
36
+ />
37
+ <button
38
+ type="submit"
39
+ className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
40
+ onMouseDown={handleSubmit}
41
+ />
42
+ </form>
43
+ );
44
+
45
  return (
46
+ <div
47
+ className={classNames(
48
+ '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',
49
+ { '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
50
+ )}
51
+ >
52
+ {editing ? (
53
+ renderDescriptionForm
54
+ ) : (
55
+ <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
56
+ {currentDescription}
57
+ <div
58
+ className={classNames(
59
+ '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%',
60
+ { 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
61
+ )}
62
+ >
63
+ <div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
64
+ <ChatActionButton
65
+ toolTipContent="Export chat"
66
+ icon="i-ph:download-simple"
67
  onClick={(event) => {
68
  event.preventDefault();
69
  exportChat(item.id);
70
  }}
 
71
  />
72
+ {onDuplicate && (
73
+ <ChatActionButton
74
+ toolTipContent="Duplicate chat"
75
+ icon="i-ph:copy"
 
 
76
  onClick={() => onDuplicate?.(item.id)}
 
77
  />
78
+ )}
79
+ <ChatActionButton
80
+ toolTipContent="Rename chat"
81
+ icon="i-ph:pencil-fill"
82
+ onClick={(event) => {
83
+ event.preventDefault();
84
+ toggleEditMode();
85
+ }}
86
+ />
87
+ <Dialog.Trigger asChild>
88
+ <ChatActionButton
89
+ toolTipContent="Delete chat"
90
+ icon="i-ph:trash"
91
+ className="[&&]:hover:text-bolt-elements-button-danger-text"
92
  onClick={(event) => {
93
  event.preventDefault();
94
  onDelete?.(event);
95
  }}
96
  />
97
+ </Dialog.Trigger>
98
+ </div>
99
  </div>
100
+ </a>
101
+ )}
102
  </div>
103
  );
104
  }
105
+
106
+ const ChatActionButton = ({
107
+ toolTipContent,
108
+ icon,
109
+ className,
110
+ onClick,
111
+ }: {
112
+ toolTipContent: string;
113
+ icon: string;
114
+ className?: string;
115
+ onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
116
+ btnTitle?: string;
117
+ }) => {
118
+ return (
119
+ <WithTooltip tooltip={toolTipContent}>
120
+ <button
121
+ type="button"
122
+ className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`}
123
+ onClick={onClick}
124
+ />
125
+ </WithTooltip>
126
+ );
127
+ };
app/lib/hooks/index.ts CHANGED
@@ -2,4 +2,5 @@ export * from './useMessageParser';
2
  export * from './usePromptEnhancer';
3
  export * from './useShortcuts';
4
  export * from './useSnapScroll';
 
5
  export { default } from './useViewport';
 
2
  export * from './usePromptEnhancer';
3
  export * from './useShortcuts';
4
  export * from './useSnapScroll';
5
+ export * from './useEditChatDescription';
6
  export { default } from './useViewport';
app/lib/hooks/useEditChatDescription.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { toast } from 'react-toastify';
4
+ import {
5
+ chatId as chatIdStore,
6
+ description as descriptionStore,
7
+ db,
8
+ updateChatDescription,
9
+ getMessages,
10
+ } from '~/lib/persistence';
11
+
12
+ interface EditChatDescriptionOptions {
13
+ initialDescription?: string;
14
+ customChatId?: string;
15
+ syncWithGlobalStore?: boolean;
16
+ }
17
+
18
+ type EditChatDescriptionHook = {
19
+ editing: boolean;
20
+ handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
21
+ handleBlur: () => Promise<void>;
22
+ handleSubmit: (event: React.FormEvent) => Promise<void>;
23
+ handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
24
+ currentDescription: string;
25
+ toggleEditMode: () => void;
26
+ };
27
+
28
+ /**
29
+ * Hook to manage the state and behavior for editing chat descriptions.
30
+ *
31
+ * Offers functions to:
32
+ * - Switch between edit and view modes.
33
+ * - Manage input changes, blur, and form submission events.
34
+ * - Save updates to IndexedDB and optionally to the global application state.
35
+ *
36
+ * @param {Object} options
37
+ * @param {string} options.initialDescription - The current chat description.
38
+ * @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
39
+ * @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
40
+ * @returns {EditChatDescriptionHook} Methods and state for managing description edits.
41
+ */
42
+ export function useEditChatDescription({
43
+ initialDescription = descriptionStore.get()!,
44
+ customChatId,
45
+ syncWithGlobalStore,
46
+ }: EditChatDescriptionOptions): EditChatDescriptionHook {
47
+ const chatIdFromStore = useStore(chatIdStore);
48
+ const [editing, setEditing] = useState(false);
49
+ const [currentDescription, setCurrentDescription] = useState(initialDescription);
50
+
51
+ const [chatId, setChatId] = useState<string>();
52
+
53
+ useEffect(() => {
54
+ setChatId(customChatId || chatIdFromStore);
55
+ }, [customChatId, chatIdFromStore]);
56
+ useEffect(() => {
57
+ setCurrentDescription(initialDescription);
58
+ }, [initialDescription]);
59
+
60
+ const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
61
+
62
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
63
+ setCurrentDescription(e.target.value);
64
+ }, []);
65
+
66
+ const fetchLatestDescription = useCallback(async () => {
67
+ if (!db || !chatId) {
68
+ return initialDescription;
69
+ }
70
+
71
+ try {
72
+ const chat = await getMessages(db, chatId);
73
+ return chat?.description || initialDescription;
74
+ } catch (error) {
75
+ console.error('Failed to fetch latest description:', error);
76
+ return initialDescription;
77
+ }
78
+ }, [db, chatId, initialDescription]);
79
+
80
+ const handleBlur = useCallback(async () => {
81
+ const latestDescription = await fetchLatestDescription();
82
+ setCurrentDescription(latestDescription);
83
+ toggleEditMode();
84
+ }, [fetchLatestDescription, toggleEditMode]);
85
+
86
+ const isValidDescription = useCallback((desc: string): boolean => {
87
+ const trimmedDesc = desc.trim();
88
+
89
+ if (trimmedDesc === initialDescription) {
90
+ toggleEditMode();
91
+ return false; // No change, skip validation
92
+ }
93
+
94
+ const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
95
+ const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
96
+
97
+ if (!lengthValid) {
98
+ toast.error('Description must be between 1 and 100 characters.');
99
+ return false;
100
+ }
101
+
102
+ if (!characterValid) {
103
+ toast.error('Description can only contain alphanumeric characters and spaces.');
104
+ return false;
105
+ }
106
+
107
+ return true;
108
+ }, []);
109
+
110
+ const handleSubmit = useCallback(
111
+ async (event: React.FormEvent) => {
112
+ event.preventDefault();
113
+
114
+ if (!isValidDescription(currentDescription)) {
115
+ return;
116
+ }
117
+
118
+ try {
119
+ if (!db) {
120
+ toast.error('Chat persistence is not available');
121
+ return;
122
+ }
123
+
124
+ if (!chatId) {
125
+ toast.error('Chat Id is not available');
126
+ return;
127
+ }
128
+
129
+ await updateChatDescription(db, chatId, currentDescription);
130
+
131
+ if (syncWithGlobalStore) {
132
+ descriptionStore.set(currentDescription);
133
+ }
134
+
135
+ toast.success('Chat description updated successfully');
136
+ } catch (error) {
137
+ toast.error('Failed to update chat description: ' + (error as Error).message);
138
+ }
139
+
140
+ toggleEditMode();
141
+ },
142
+ [currentDescription, db, chatId, initialDescription, customChatId],
143
+ );
144
+
145
+ const handleKeyDown = useCallback(
146
+ async (e: React.KeyboardEvent<HTMLInputElement>) => {
147
+ if (e.key === 'Escape') {
148
+ await handleBlur();
149
+ }
150
+ },
151
+ [handleBlur],
152
+ );
153
+
154
+ return {
155
+ editing,
156
+ handleChange,
157
+ handleBlur,
158
+ handleSubmit,
159
+ handleKeyDown,
160
+ currentDescription,
161
+ toggleEditMode,
162
+ };
163
+ }
app/lib/persistence/ChatDescription.client.tsx CHANGED
@@ -1,6 +1,68 @@
1
  import { useStore } from '@nanostores/react';
2
- import { description } from './useChatHistory';
 
 
 
3
 
4
  export function ChatDescription() {
5
- return useStore(description);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
 
1
  import { useStore } from '@nanostores/react';
2
+ import { TooltipProvider } from '@radix-ui/react-tooltip';
3
+ import WithTooltip from '~/components/ui/Tooltip';
4
+ import { useEditChatDescription } from '~/lib/hooks';
5
+ import { description as descriptionStore } from '~/lib/persistence';
6
 
7
  export function ChatDescription() {
8
+ const initialDescription = useStore(descriptionStore)!;
9
+
10
+ const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
11
+ useEditChatDescription({
12
+ initialDescription,
13
+ syncWithGlobalStore: true,
14
+ });
15
+
16
+ if (!initialDescription) {
17
+ // doing this to prevent showing edit button until chat description is set
18
+ return null;
19
+ }
20
+
21
+ return (
22
+ <div className="flex items-center justify-center">
23
+ {editing ? (
24
+ <form onSubmit={handleSubmit} className="flex items-center justify-center">
25
+ <input
26
+ type="text"
27
+ className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2 w-fit"
28
+ autoFocus
29
+ value={currentDescription}
30
+ onChange={handleChange}
31
+ onBlur={handleBlur}
32
+ onKeyDown={handleKeyDown}
33
+ style={{ width: `${Math.max(currentDescription.length * 8, 100)}px` }}
34
+ />
35
+ <TooltipProvider>
36
+ <WithTooltip tooltip="Save title">
37
+ <div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent">
38
+ <button
39
+ type="submit"
40
+ className="i-ph:check-bold scale-110 hover:text-bolt-elements-item-contentAccent"
41
+ onMouseDown={handleSubmit}
42
+ />
43
+ </div>
44
+ </WithTooltip>
45
+ </TooltipProvider>
46
+ </form>
47
+ ) : (
48
+ <>
49
+ {currentDescription}
50
+ <TooltipProvider>
51
+ <WithTooltip tooltip="Rename chat">
52
+ <div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent ml-2">
53
+ <button
54
+ type="button"
55
+ className="i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent"
56
+ onClick={(event) => {
57
+ event.preventDefault();
58
+ toggleEditMode();
59
+ }}
60
+ />
61
+ </div>
62
+ </WithTooltip>
63
+ </TooltipProvider>
64
+ </>
65
+ )}
66
+ </div>
67
+ );
68
  }
app/lib/persistence/db.ts CHANGED
@@ -52,17 +52,23 @@ export async function setMessages(
52
  messages: Message[],
53
  urlId?: string,
54
  description?: string,
 
55
  ): Promise<void> {
56
  return new Promise((resolve, reject) => {
57
  const transaction = db.transaction('chats', 'readwrite');
58
  const store = transaction.objectStore('chats');
59
 
 
 
 
 
 
60
  const request = store.put({
61
  id,
62
  messages,
63
  urlId,
64
  description,
65
- timestamp: new Date().toISOString(),
66
  });
67
 
68
  request.onsuccess = () => resolve();
@@ -212,3 +218,17 @@ export async function createChatFromMessages(
212
 
213
  return newUrlId; // Return the urlId instead of id for navigation
214
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  messages: Message[],
53
  urlId?: string,
54
  description?: string,
55
+ timestamp?: string,
56
  ): Promise<void> {
57
  return new Promise((resolve, reject) => {
58
  const transaction = db.transaction('chats', 'readwrite');
59
  const store = transaction.objectStore('chats');
60
 
61
+ if (timestamp && isNaN(Date.parse(timestamp))) {
62
+ reject(new Error('Invalid timestamp'));
63
+ return;
64
+ }
65
+
66
  const request = store.put({
67
  id,
68
  messages,
69
  urlId,
70
  description,
71
+ timestamp: timestamp ?? new Date().toISOString(),
72
  });
73
 
74
  request.onsuccess = () => resolve();
 
218
 
219
  return newUrlId; // Return the urlId instead of id for navigation
220
  }
221
+
222
+ export async function updateChatDescription(db: IDBDatabase, id: string, description: string): Promise<void> {
223
+ const chat = await getMessages(db, id);
224
+
225
+ if (!chat) {
226
+ throw new Error('Chat not found');
227
+ }
228
+
229
+ if (!description.trim()) {
230
+ throw new Error('Description cannot be empty');
231
+ }
232
+
233
+ await setMessages(db, id, chat.messages, chat.urlId, description, chat.timestamp);
234
+ }
app/lib/runtime/action-runner.ts CHANGED
@@ -100,6 +100,10 @@ export class ActionRunner {
100
  .catch((error) => {
101
  console.error('Action failed:', error);
102
  });
 
 
 
 
103
  }
104
 
105
  async #executeAction(actionId: string, isStreaming: boolean = false) {
 
100
  .catch((error) => {
101
  console.error('Action failed:', error);
102
  });
103
+
104
+ await this.#currentExecutionPromise;
105
+
106
+ return;
107
  }
108
 
109
  async #executeAction(actionId: string, isStreaming: boolean = false) {