Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 185 additions & 11 deletions src/frontend/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
Expand All @@ -27,6 +28,7 @@ interface SidebarProps {
onNewChat: () => void;
onSelectChat: (chatId: string) => void;
onDeleteChat: (chatId: string) => void;
onDeleteChats: (chatIds: string[]) => void;
onDeleteAllChats: () => void;
onRenameChat: (chatId: string, newTitle: string) => void;
}
Expand All @@ -41,7 +43,7 @@ function SidebarComponent({
onNewChat,
onSelectChat,
onDeleteChat,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onDeleteChats,
onDeleteAllChats,
onRenameChat,
}: SidebarProps) {
Expand All @@ -51,7 +53,10 @@ function SidebarComponent({
const [editingChat, setEditingChat] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState('');
const [deletingChatId, setDeletingChatId] = useState<string | null>(null);

const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedChatIds, setSelectedChatIds] = useState<Set<string>>(new Set());
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);

const filteredChats = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
Expand All @@ -62,6 +67,39 @@ function SidebarComponent({
);
}, [chatHistory, searchQuery]);

const allFilteredSelected =
filteredChats.length > 0 && filteredChats.every((chat) => selectedChatIds.has(chat.id));

const exitSelectionMode = () => {
setIsSelectionMode(false);
setSelectedChatIds(new Set());
setShowDeleteSelectedDialog(false);
};

const toggleChatSelected = (chatId: string) => {
setSelectedChatIds((prev) => {
const next = new Set(prev);
if (next.has(chatId)) {
next.delete(chatId);
} else {
next.add(chatId);
}
return next;
});
};

const toggleSelectAllFiltered = () => {
setSelectedChatIds((prev) => {
const next = new Set(prev);
if (allFilteredSelected) {
filteredChats.forEach((chat) => next.delete(chat.id));
} else {
filteredChats.forEach((chat) => next.add(chat.id));
}
return next;
});
};

const handleRename = (chatId: string, title: string) => {
setEditingChat(chatId);
setEditTitle(title);
Expand All @@ -73,6 +111,12 @@ function SidebarComponent({
setEditTitle('');
};

const confirmDeleteSelected = () => {
const ids = [...selectedChatIds];
onDeleteChats(ids);
exitSelectionMode();
};

return (
<div className="flex flex-col h-full min-h-0 bg-sidebar border-r border-sidebar-border text-sidebar-foreground">
{/* New Chat button */}
Expand All @@ -94,16 +138,75 @@ function SidebarComponent({
</div>
)}

{/* Separator + label */}
{/* Separator + label / selection toolbar */}
<div className="shrink-0 px-3 pt-3 pb-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recent chats</p>
{isSelectionMode ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Checkbox
id="select-all-chats"
isChecked={allFilteredSelected}
onChange={toggleSelectAllFiltered}
aria-label="Select all visible chats"
/>
<p className="text-xs font-medium text-muted-foreground truncate">
{selectedChatIds.size} selected
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => selectedChatIds.size > 0 && setShowDeleteSelectedDialog(true)}
disabled={selectedChatIds.size === 0}
className="text-xs text-destructive hover:text-destructive/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
aria-label={`Delete ${selectedChatIds.size} selected chats`}
>
Delete
</button>
<button
type="button"
onClick={exitSelectionMode}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent chats
</p>
{chatHistory.length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsSelectionMode(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Select chats"
>
Select
</button>
<button
type="button"
onClick={() => setShowDeleteAllDialog(true)}
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
title="Delete all chats"
>
Clear all
</button>
</div>
)}
</div>
)}
</div>

{/* Chat list */}
<div
className="flex-1 min-h-0 overflow-y-auto chat-scroll px-1.5"
role="listbox"
aria-label="Chat history"
aria-multiselectable={isSelectionMode}
>
{filteredChats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
Expand All @@ -116,6 +219,7 @@ function SidebarComponent({
<div className="space-y-0.5">
{filteredChats.map((chat) => {
const isActive = currentChatId === chat.id;
const isChecked = selectedChatIds.has(chat.id);

const focusNeighbor = (delta: number) => {
const idx = filteredChats.findIndex((c) => c.id === chat.id);
Expand All @@ -127,7 +231,11 @@ function SidebarComponent({
const onOptionKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectChat(chat.id);
if (isSelectionMode) {
toggleChatSelected(chat.id);
} else {
onSelectChat(chat.id);
}
return;
}
if (e.key === 'ArrowDown') {
Expand All @@ -147,16 +255,34 @@ function SidebarComponent({
id={`sidebar-chat-option-${chat.id}`}
role="option"
tabIndex={editingChat === chat.id ? -1 : 0}
aria-selected={isActive}
aria-selected={isSelectionMode ? isChecked : isActive}
className={cn(
'group flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive
isSelectionMode && isChecked
? 'bg-secondary text-foreground'
: 'text-foreground/70 hover:bg-secondary/50 hover:text-foreground'
: isActive && !isSelectionMode
? 'bg-secondary text-foreground'
: 'text-foreground/70 hover:bg-secondary/50 hover:text-foreground'
)}
onClick={() => onSelectChat(chat.id)}
onClick={() => {
if (isSelectionMode) {
toggleChatSelected(chat.id);
} else {
onSelectChat(chat.id);
}
}}
onKeyDown={onOptionKeyDown}
>
{isSelectionMode && (
<Checkbox
id={`select-chat-${chat.id}`}
isChecked={isChecked}
onChange={() => toggleChatSelected(chat.id)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select chat: ${chat.title}`}
/>
)}

{editingChat === chat.id ? (
<input
value={editTitle}
Expand All @@ -177,7 +303,7 @@ function SidebarComponent({
) : (
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{chat.title}</p>
{isActive && activeSubAgent && (
{isActive && activeSubAgent && !isSelectionMode && (
<div className="flex items-center gap-1.5 mt-0.5">
<Label
isCompact
Expand All @@ -191,7 +317,7 @@ function SidebarComponent({
</div>
)}

{editingChat !== chat.id && (
{!isSelectionMode && editingChat !== chat.id && (
<div className="flex shrink-0 gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="plain"
Expand Down Expand Up @@ -315,6 +441,54 @@ function SidebarComponent({
</ModalFooter>
</Modal>

<Modal
variant={ModalVariant.small}
isOpen={showDeleteSelectedDialog}
onClose={() => setShowDeleteSelectedDialog(false)}
aria-label="Delete selected chats confirmation"
>
<ModalHeader title="Delete selected chats?" />
<ModalBody>
This will permanently delete {selectedChatIds.size} selected conversation
{selectedChatIds.size === 1 ? '' : 's'}. This action cannot be undone.
</ModalBody>
<ModalFooter>
<Button variant="danger" onClick={confirmDeleteSelected}>
Delete selected
</Button>
<Button variant="link" onClick={() => setShowDeleteSelectedDialog(false)}>
Cancel
</Button>
</ModalFooter>
</Modal>

<Modal
variant={ModalVariant.small}
isOpen={showDeleteAllDialog}
onClose={() => setShowDeleteAllDialog(false)}
aria-label="Delete all chats confirmation"
>
<ModalHeader title="Delete all chats?" />
<ModalBody>
This will permanently delete all {chatHistory.length} conversations from your history.
This action cannot be undone.
</ModalBody>
<ModalFooter>
<Button
variant="danger"
onClick={() => {
onDeleteAllChats();
setShowDeleteAllDialog(false);
}}
>
Delete all
</Button>
<Button variant="link" onClick={() => setShowDeleteAllDialog(false)}>
Cancel
</Button>
</ModalFooter>
</Modal>

</div>
);
}
Expand Down
43 changes: 40 additions & 3 deletions src/frontend/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ export function AppLayout({ children }: AppLayoutProps) {
const activeSubAgent = streamingState?.activeSubAgent ?? null;

useEffect(() => {
const loadedChats = chatStorage.loadChats();
const deletedThreadIds = chatStorage.getDeletedThreadIds();
const loadedChats = chatStorage
.loadChats()
.filter((c) => !deletedThreadIds.has(c.id));
if (loadedChats.length > 0) {
const mapped: ChatItem[] = loadedChats.map((c) => ({
...c,
Expand All @@ -116,10 +119,12 @@ export function AppLayout({ children }: AppLayoutProps) {
async function loadUserHistory() {
try {
dispatch(setLoadingThreads(true));
const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username);
const deletedThreadIds = chatStorage.getDeletedThreadIds();
const history = (await getAllThreadsByUserId(window.USER_DATA.preferred_username))
.filter((t) => !deletedThreadIds.has(t.id));

const backendIds = new Set(history.map((t) => t.id));
const local = chatsRef.current;
const local = chatsRef.current.filter((c) => !deletedThreadIds.has(c.id));

const surviving = local.filter(
(c) => backendIds.has(c.id) || isClientCreatedChat(c.id),
Expand Down Expand Up @@ -254,6 +259,7 @@ export function AppLayout({ children }: AppLayoutProps) {

const handleDeleteChat = useCallback(
(chatId: string) => {
chatStorage.markThreadDeleted(chatId);
dispatch(deleteChat(chatId));
dispatch(addToast({ title: 'Chat deleted', variant: 'success' }));
if (location.pathname === `/chat/${chatId}`) {
Expand All @@ -267,13 +273,43 @@ export function AppLayout({ children }: AppLayoutProps) {

const handleDeleteAllChats = useCallback(() => {
const ids = chats.map((c) => c.id);
chatStorage.markThreadsDeleted(ids);
dispatch(clearAllChats());
chatStorage.clearChats();
dispatch(addToast({ title: 'All chats deleted', variant: 'success' }));
navigate('/');
ids.forEach((id) => deleteThread(id).catch(() => {}));
}, [dispatch, chats, navigate]);

const handleDeleteChats = useCallback(
(chatIds: string[]) => {
if (chatIds.length === 0) return;

const idSet = new Set(chatIds);
chatStorage.markThreadsDeleted(chatIds);
chatIds.forEach((id) => dispatch(deleteChat(id)));

const remaining = chats.filter((c) => !idSet.has(c.id));
if (remaining.length === 0) {
chatStorage.clearChats();
}

dispatch(
addToast({
title: chatIds.length === 1 ? 'Chat deleted' : `${chatIds.length} chats deleted`,
variant: 'success',
})
);

if (currentChatId && idSet.has(currentChatId)) {
navigate(remaining.length > 0 ? `/chat/${remaining[0].id}` : '/');
}

chatIds.forEach((id) => deleteThread(id).catch(() => {}));
},
[dispatch, chats, navigate, currentChatId]
);

const handleRenameChat = useCallback(
(chatId: string, newTitle: string) => {
dispatch(updateChat({ id: chatId, updates: { title: newTitle.trim() || 'Untitled Chat' } }));
Expand Down Expand Up @@ -350,6 +386,7 @@ export function AppLayout({ children }: AppLayoutProps) {
onNewChat={handleNewChat}
onSelectChat={handleSelectChat}
onDeleteChat={handleDeleteChat}
onDeleteChats={handleDeleteChats}
onDeleteAllChats={handleDeleteAllChats}
onRenameChat={handleRenameChat}
/>
Expand Down
Loading
Loading