PuneetP16 commited on
Commit
5335580
·
1 Parent(s): 0d49c74

[feat]: Implement chat description editing in sidebar and header, add visual cue for active chat in sidebar

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-15 group-hover:from-99%',
60
+ { 'from-bolt-elements-background-depth-3 w-15 from-99%': 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,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import {
4
+ chatId as chatIdStore,
5
+ description as descriptionStore,
6
+ db,
7
+ updateChatDescription,
8
+ getMessages,
9
+ } from '~/lib/persistence';
10
+
11
+ interface EditChatDescriptionOptions {
12
+ initialDescription?: string;
13
+ customChatId?: string;
14
+ syncWithGlobalStore?: boolean;
15
+ }
16
+
17
+ type EditChatDescriptionHook = {
18
+ editing: boolean;
19
+ handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
20
+ handleBlur: () => Promise<void>;
21
+ handleSubmit: (event: React.FormEvent) => Promise<void>;
22
+ handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
23
+ currentDescription: string;
24
+ toggleEditMode: () => void;
25
+ };
26
+
27
+ /**
28
+ * Hook to manage the state and behavior for editing chat descriptions.
29
+ *
30
+ * Offers functions to:
31
+ * - Switch between edit and view modes.
32
+ * - Manage input changes, blur, and form submission events.
33
+ * - Save updates to IndexedDB and optionally to the global application state.
34
+ *
35
+ * @param {Object} options
36
+ * @param {string} options.initialDescription - The current chat description.
37
+ * @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
38
+ * @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
39
+ * @returns {EditChatDescriptionHook} Methods and state for managing description edits.
40
+ */
41
+ export function useEditChatDescription({
42
+ initialDescription = descriptionStore.get()!,
43
+ customChatId,
44
+ syncWithGlobalStore,
45
+ }: EditChatDescriptionOptions): EditChatDescriptionHook {
46
+ const chatId = (customChatId || chatIdStore.get()) as string;
47
+ const [editing, setEditing] = useState(false);
48
+ const [currentDescription, setCurrentDescription] = useState(initialDescription);
49
+
50
+ useEffect(() => {
51
+ setCurrentDescription(initialDescription);
52
+ }, [initialDescription]);
53
+
54
+ const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
55
+
56
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
57
+ setCurrentDescription(e.target.value);
58
+ }, []);
59
+
60
+ const fetchLatestDescription = useCallback(async () => {
61
+ if (!db || !chatId) {
62
+ return initialDescription;
63
+ }
64
+
65
+ try {
66
+ const chat = await getMessages(db, chatId);
67
+ return chat?.description || initialDescription;
68
+ } catch (error) {
69
+ console.error('Failed to fetch latest description:', error);
70
+ return initialDescription;
71
+ }
72
+ }, [db, chatId, initialDescription]);
73
+
74
+ const handleBlur = useCallback(async () => {
75
+ const latestDescription = await fetchLatestDescription();
76
+ setCurrentDescription(latestDescription);
77
+ toggleEditMode();
78
+ }, [fetchLatestDescription, toggleEditMode]);
79
+
80
+ const isValidDescription = useCallback((desc: string): boolean => {
81
+ const trimmedDesc = desc.trim();
82
+
83
+ if (trimmedDesc === initialDescription) {
84
+ toggleEditMode();
85
+ return false; // No change, skip validation
86
+ }
87
+
88
+ const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
89
+ const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
90
+
91
+ if (!lengthValid) {
92
+ toast.error('Description must be between 1 and 100 characters.');
93
+ return false;
94
+ }
95
+
96
+ if (!characterValid) {
97
+ toast.error('Description can only contain alphanumeric characters and spaces.');
98
+ return false;
99
+ }
100
+
101
+ return true;
102
+ }, []);
103
+
104
+ const handleSubmit = useCallback(
105
+ async (event: React.FormEvent) => {
106
+ event.preventDefault();
107
+
108
+ if (!isValidDescription(currentDescription)) {
109
+ return;
110
+ }
111
+
112
+ try {
113
+ if (!db) {
114
+ toast.error('Chat persistence is not available');
115
+ return;
116
+ }
117
+
118
+ await updateChatDescription(db, chatId, currentDescription);
119
+
120
+ if (syncWithGlobalStore) {
121
+ descriptionStore.set(currentDescription);
122
+ }
123
+
124
+ toast.success('Chat description updated successfully');
125
+ } catch (error) {
126
+ toast.error('Failed to update chat description: ' + (error as Error).message);
127
+ }
128
+
129
+ toggleEditMode();
130
+ },
131
+ [currentDescription, db, chatId, initialDescription, customChatId],
132
+ );
133
+
134
+ const handleKeyDown = useCallback(
135
+ async (e: React.KeyboardEvent<HTMLInputElement>) => {
136
+ if (e.key === 'Escape') {
137
+ await handleBlur();
138
+ }
139
+ },
140
+ [handleBlur],
141
+ );
142
+
143
+ return {
144
+ editing,
145
+ handleChange,
146
+ handleBlur,
147
+ handleSubmit,
148
+ handleKeyDown,
149
+ currentDescription,
150
+ toggleEditMode,
151
+ };
152
+ }
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-background-depth-3">
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-background-depth-3 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,9 @@ 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
+ // eslint-disable-next-line consistent-return -- TODO: fix this consistent-return error
105
+ return this.#currentExecutionPromise;
106
  }
107
 
108
  async #executeAction(actionId: string, isStreaming: boolean = false) {