changes
This commit is contained in:
+103
-30
@@ -112,6 +112,11 @@ export default function ChatLayout() {
|
|||||||
const [_isGenerating, setIsGenerating] = useState(false);
|
const [_isGenerating, setIsGenerating] = useState(false);
|
||||||
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
|
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Inline editing state
|
||||||
|
const [editingChatId, setEditingChatId] = useState<string | null>(null);
|
||||||
|
const [editingTitle, setEditingTitle] = useState('');
|
||||||
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Scroll state
|
// Scroll state
|
||||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const isUserScrolledUp = useRef(false);
|
const isUserScrolledUp = useRef(false);
|
||||||
@@ -147,20 +152,28 @@ export default function ChatLayout() {
|
|||||||
|
|
||||||
// Restore scroll position for a chat
|
// Restore scroll position for a chat
|
||||||
const restoreScrollPosition = (chatId: string) => {
|
const restoreScrollPosition = (chatId: string) => {
|
||||||
setTimeout(() => {
|
// Use requestAnimationFrame to wait for DOM to update after messages render
|
||||||
const viewport = scrollViewportRef.current;
|
requestAnimationFrame(() => {
|
||||||
if (!viewport) {
|
requestAnimationFrame(() => {
|
||||||
return;
|
const viewport = scrollViewportRef.current;
|
||||||
}
|
if (!viewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const savedPosition = getScrollPosition(chatId);
|
const savedPosition = getScrollPosition(chatId);
|
||||||
if (savedPosition !== null) {
|
if (savedPosition !== null) {
|
||||||
viewport.scrollTop = savedPosition;
|
// Temporarily disable smooth scrolling for instant restore
|
||||||
} else {
|
viewport.style.scrollBehavior = 'auto';
|
||||||
// New chat or no saved position - scroll to bottom
|
viewport.scrollTop = savedPosition;
|
||||||
viewport.scrollTop = viewport.scrollHeight;
|
viewport.style.scrollBehavior = '';
|
||||||
}
|
} else {
|
||||||
}, 0);
|
// New chat or no saved position - scroll to bottom
|
||||||
|
viewport.style.scrollBehavior = 'auto';
|
||||||
|
viewport.scrollTop = viewport.scrollHeight;
|
||||||
|
viewport.style.scrollBehavior = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-scroll when messages change (during streaming)
|
// Auto-scroll when messages change (during streaming)
|
||||||
@@ -397,36 +410,66 @@ export default function ChatLayout() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chat menu handlers
|
// Chat menu handlers - start inline editing mode
|
||||||
const handleRenameChat = async (chatId: string) => {
|
const handleRenameChat = (chatId: string) => {
|
||||||
const chat = chats.find((c) => c.id === chatId);
|
const chat = chats.find((c) => c.id === chatId);
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setEditingChatId(chatId);
|
||||||
|
setEditingTitle(chat.title);
|
||||||
|
// Focus the input after React renders
|
||||||
|
setTimeout(() => editInputRef.current?.focus(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
const newTitle = window.prompt('Enter new chat title:', chat.title);
|
// Save the renamed chat title
|
||||||
if (!newTitle || newTitle === chat.title) {
|
const saveRenamedChat = async () => {
|
||||||
|
if (!editingChatId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = chats.find((c) => c.id === editingChatId);
|
||||||
|
const newTitle = editingTitle.trim();
|
||||||
|
|
||||||
|
// Cancel if empty or unchanged
|
||||||
|
if (!newTitle || newTitle === chat?.title) {
|
||||||
|
setEditingChatId(null);
|
||||||
|
setEditingTitle('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update in database
|
// Update in database
|
||||||
await fetch(`/api/chats/${chatId}`, {
|
await fetch(`/api/chats/${editingChatId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: newTitle }),
|
body: JSON.stringify({ title: newTitle }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setChats((prev) => prev.map((c) => (c.id === chatId ? { ...c, title: newTitle } : c)));
|
setChats((prev) => prev.map((c) => (c.id === editingChatId ? { ...c, title: newTitle } : c)));
|
||||||
|
|
||||||
// Update localStorage
|
// Update localStorage
|
||||||
const localChat = getLocalChat(chatId);
|
const localChat = getLocalChat(editingChatId);
|
||||||
if (localChat) {
|
if (localChat) {
|
||||||
saveLocalChat({ ...localChat, title: newTitle });
|
saveLocalChat({ ...localChat, title: newTitle });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to rename chat:', e);
|
console.error('Failed to rename chat:', e);
|
||||||
|
} finally {
|
||||||
|
setEditingChatId(null);
|
||||||
|
setEditingTitle('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard events in rename input
|
||||||
|
const handleRenameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
saveRenamedChat();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
setEditingChatId(null);
|
||||||
|
setEditingTitle('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -577,22 +620,52 @@ export default function ChatLayout() {
|
|||||||
transition: 'background-color 0.2s',
|
transition: 'background-color 0.2s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UnstyledButton
|
{editingChatId === chat.id ? (
|
||||||
onClick={() => handleSelectChat(chat)}
|
// Inline editing mode
|
||||||
p="sm"
|
<Group wrap="nowrap" gap="xs" p="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap" gap="xs">
|
|
||||||
{chat.pinned ? (
|
{chat.pinned ? (
|
||||||
<IconPin size={18} color="gray" style={{ minWidth: 18 }} />
|
<IconPin size={18} color="gray" style={{ minWidth: 18 }} />
|
||||||
) : (
|
) : (
|
||||||
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
|
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
|
||||||
)}
|
)}
|
||||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
<TextInput
|
||||||
{chat.title}
|
ref={editInputRef}
|
||||||
</Text>
|
value={editingTitle}
|
||||||
|
onChange={(e) => setEditingTitle(e.currentTarget.value)}
|
||||||
|
onBlur={saveRenamedChat}
|
||||||
|
onKeyDown={handleRenameKeyDown}
|
||||||
|
size="xs"
|
||||||
|
variant="unstyled"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
padding: 0,
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: 'unset',
|
||||||
|
fontSize: 'var(--mantine-font-size-sm)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
) : (
|
||||||
|
// Normal display mode
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSelectChat(chat)}
|
||||||
|
p="sm"
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
{chat.pinned ? (
|
||||||
|
<IconPin size={18} color="gray" style={{ minWidth: 18 }} />
|
||||||
|
) : (
|
||||||
|
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
|
||||||
|
)}
|
||||||
|
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{chat.title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu position="bottom-end" withArrow>
|
<Menu position="bottom-end" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
|
|||||||
Reference in New Issue
Block a user