group select chats

This commit is contained in:
2026-03-04 15:51:55 +00:00
parent 3249378984
commit 98a6a69477
4 changed files with 311 additions and 39 deletions
+6 -1
View File
@@ -262,13 +262,17 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
}
const body = await ctx.req.json().catch(() => ({}));
const sessionIds = Array.isArray(body.sessionIds) ? (body.sessionIds as string[]) : undefined;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
const userHome = getHomeDir(user.email);
try {
const sessions = await storage.listUserSessions(userHome, contextFilter);
const allSessions = await storage.listUserSessions(userHome, contextFilter);
const sessions = sessionIds
? allSessions.filter((s) => sessionIds.includes(s.id))
: allSessions;
let deleted = 0;
for (const session of sessions) {
try {
@@ -283,6 +287,7 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
deleted,
total: sessions.length,
context: contextFilter?.context,
sessionIds: sessionIds?.length,
});
return ctx.json({ success: true, deleted });
} catch (err) {
@@ -4,6 +4,7 @@ import { useChatGroups } from 'state/useChatGroups';
type CreateGroupDialogProps = {
onClose: () => void;
onCreateWithSessions?: (name: string, slug: string, description?: string) => Promise<void>;
};
function toSlug(name: string): string {
@@ -13,7 +14,7 @@ function toSlug(name: string): string {
.replace(/^-|-$/g, '');
}
export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
export function CreateGroupDialog({ onClose, onCreateWithSessions }: CreateGroupDialogProps) {
const { createGroup } = useChatGroups();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
@@ -38,7 +39,11 @@ export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
setError(null);
try {
await createGroup(name.trim(), slug, description.trim() || undefined);
if (onCreateWithSessions) {
await onCreateWithSessions(name.trim(), slug, description.trim() || undefined);
} else {
await createGroup(name.trim(), slug, description.trim() || undefined);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router';
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-react';
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus, CheckSquare, Trash2, FolderInput } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { useChatGroups } from 'state/useChatGroups';
@@ -20,12 +21,20 @@ export const SessionList = () => {
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: undefined;
const { sessions, deleteSession } = useChatSessions(contextFilter);
const { groups } = useChatGroups();
const { sessions, deleteSession, deleteSessions } = useChatSessions(contextFilter);
const { groups, moveSession, createGroup } = useChatGroups();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [showCreateGroup, setShowCreateGroup] = useState(false);
// Selection mode state
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [showCreateGroupForMove, setShowCreateGroupForMove] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const scrolledRef = useRef(false);
const selectedRef = useCallback(
(node: HTMLDivElement | null) => {
@@ -41,18 +50,99 @@ export const SessionList = () => {
scrolledRef.current = false;
}, [selected?.id]);
// Exit selection mode on Escape
useEffect(() => {
if (!selectionMode) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectionMode(false);
setSelectedIds(new Set());
setShowMoveMenu(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectionMode]);
const exitSelectionMode = () => {
setSelectionMode(false);
setSelectedIds(new Set());
setShowMoveMenu(false);
setShowDeleteConfirm(false);
};
const toggleSelection = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleGroup = (slug: string) => {
setCollapsed(prev => {
setCollapsed((prev) => {
const next = new Set(prev);
next.has(slug) ? next.delete(slug) : next.add(slug);
return next;
});
};
const ungrouped = sessions.filter(s => !s.groupSlug);
const grouped = groups.map(g => ({
const toggleGroupSelection = (groupSessions: typeof sessions) => {
const ids = groupSessions.map((s) => s.id);
const allSelected = ids.every((id) => selectedIds.has(id));
setSelectedIds((prev) => {
const next = new Set(prev);
if (allSelected) {
ids.forEach((id) => next.delete(id));
} else {
ids.forEach((id) => next.add(id));
}
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(sessions.map((s) => s.id)));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
const handleBulkDelete = async () => {
setIsBulkDeleting(true);
try {
const ids = [...selectedIds];
// Clear selected panel if deleting the active session
if (selected && selectedIds.has(selected.id)) {
setSelected(null);
navigate('/chat', { replace: true });
}
await deleteSessions(ids);
exitSelectionMode();
} finally {
setIsBulkDeleting(false);
}
};
const handleBulkMove = async (groupSlug: string | null) => {
const ids = [...selectedIds];
await Promise.all(ids.map((id) => moveSession(id, groupSlug)));
setShowMoveMenu(false);
exitSelectionMode();
};
const handleCreateGroupAndMove = async (name: string, slug: string, description?: string) => {
const ids = [...selectedIds];
await createGroup(name, slug, description, ids);
setShowCreateGroupForMove(false);
exitSelectionMode();
};
const ungrouped = sessions.filter((s) => !s.groupSlug);
const grouped = groups.map((g) => ({
...g,
sessions: sessions.filter(s => s.groupSlug === g.slug)
sessions: sessions.filter((s) => s.groupSlug === g.slug),
}));
const handleSelect = (session: (typeof sessions)[number]) => {
@@ -69,19 +159,31 @@ export const SessionList = () => {
};
const renderSession = (session: (typeof sessions)[number]) => {
const isSelected = selected?.id === session.id;
const isActive = selected?.id === session.id;
const isChecked = selectedIds.has(session.id);
return (
<div
key={session.id}
ref={isSelected ? selectedRef : undefined}
ref={isActive ? selectedRef : undefined}
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
isSelected
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
selectionMode && isChecked
? 'border-duck-teal/40 bg-duck-teal/10 dark:bg-duck-teal/15'
: isActive
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
{selectionMode && (
<div className="shrink-0 pl-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSelection(session.id)}
className="border-duck-dark/30 dark:border-foreground/30 data-[state=checked]:bg-duck-teal data-[state=checked]:border-duck-teal"
/>
</div>
)}
<button
onClick={() => handleSelect(session)}
onClick={() => (selectionMode ? toggleSelection(session.id) : handleSelect(session))}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
@@ -104,7 +206,7 @@ export const SessionList = () => {
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{(() => {
if (!session.model.includes('/')) return session.model;
const [provider, modelId] = session.model.split('/') as [string, string];
// Handle local providers - show friendly name
@@ -112,7 +214,7 @@ export const SessionList = () => {
const friendlyName = getProviderDisplayName(provider);
return `${friendlyName} - ${modelId}`;
}
// Regular providers - just replace / with -
return session.model.replace('/', ' - ');
})()}
@@ -120,9 +222,11 @@ export const SessionList = () => {
)}
</div>
</button>
<div className="shrink-0 mr-2">
<SessionContextMenu session={session} onDelete={handleDelete} />
</div>
{!selectionMode && (
<div className="shrink-0 mr-2">
<SessionContextMenu session={session} onDelete={handleDelete} />
</div>
)}
</div>
);
};
@@ -133,23 +237,51 @@ export const SessionList = () => {
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex gap-2">
<button
onClick={() => setShowCreateGroup(true)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
<FolderPlus className="h-3.5 w-3.5" />
New Group
</button>
<button
onClick={() => {
setSelected({ id: `new:${Date.now()}` });
navigate('/chat/new', { replace: true });
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
{selectionMode ? (
<>
<button
onClick={selectedIds.size === sessions.length ? deselectAll : selectAll}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
{selectedIds.size === sessions.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={exitSelectionMode}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
Cancel
</button>
</>
) : (
<>
{sessions.length > 0 && (
<button
onClick={() => setSelectionMode(true)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
<CheckSquare className="h-3.5 w-3.5" />
Select
</button>
)}
<button
onClick={() => setShowCreateGroup(true)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
<FolderPlus className="h-3.5 w-3.5" />
New Group
</button>
<button
onClick={() => {
setSelected({ id: `new:${Date.now()}` });
navigate('/chat/new', { replace: true });
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
</>
)}
</div>
</div>
@@ -164,6 +296,14 @@ export const SessionList = () => {
{/* Ungrouped sessions */}
{ungrouped.length > 0 && (
<div className="space-y-1.5">
{selectionMode && ungrouped.length > 1 && (
<button
onClick={() => toggleGroupSelection(ungrouped)}
className="text-xs text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark/70 dark:hover:text-foreground/70 px-3 py-1"
>
{ungrouped.every((s) => selectedIds.has(s.id)) ? 'Deselect ungrouped' : 'Select ungrouped'}
</button>
)}
{ungrouped.map(renderSession)}
</div>
)}
@@ -172,6 +312,15 @@ export const SessionList = () => {
{grouped.map((group) => (
<div key={group.slug} className="space-y-1.5">
<div className="flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors">
{selectionMode && group.sessions.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={group.sessions.length > 0 && group.sessions.every((s) => selectedIds.has(s.id))}
onCheckedChange={() => toggleGroupSelection(group.sessions)}
className="border-duck-dark/30 dark:border-foreground/30 data-[state=checked]:bg-duck-teal data-[state=checked]:border-duck-teal"
/>
</div>
)}
<div onClick={() => toggleGroup(group.slug)} className="flex items-center gap-2 flex-1 min-w-0">
<ChevronRight
className={`h-4 w-4 transition-transform ${!collapsed.has(group.slug) ? 'rotate-90' : ''}`}
@@ -180,7 +329,7 @@ export const SessionList = () => {
<span className="font-medium text-sm text-duck-dark/70 dark:text-foreground/70">{group.name}</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">({group.sessionCount})</span>
</div>
<GroupContextMenu group={group} />
{!selectionMode && <GroupContextMenu group={group} />}
</div>
{!collapsed.has(group.slug) && (
<div className="ml-6 space-y-1.5">
@@ -191,7 +340,106 @@ export const SessionList = () => {
))}
</div>
{/* Floating action bar */}
{selectionMode && selectedIds.size > 0 && (
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-t border-duck-dark/10 dark:border-foreground/10 bg-background/95 backdrop-blur-sm">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">
{selectedIds.size} selected
</span>
<div className="flex gap-2 relative">
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 rounded-md bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400 cursor-pointer h-8 px-3 text-xs font-medium"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={() => setShowMoveMenu(!showMoveMenu)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-8 px-3 text-xs font-medium"
>
<FolderInput className="h-3.5 w-3.5" />
Move to Group
</button>
{showMoveMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMoveMenu(false)} />
<div className="absolute right-0 bottom-full mb-1 z-50 min-w-[180px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-duck-dark/50 dark:text-foreground/50 border-b border-duck-dark/10 dark:border-foreground/10">
Move to:
</div>
<button
onClick={() => handleBulkMove(null)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
Ungrouped
</button>
{groups.map((group) => (
<button
key={group.slug}
onClick={() => handleBulkMove(group.slug)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
{group.name}
</button>
))}
<div className="border-t border-duck-dark/10 dark:border-foreground/10">
<button
onClick={() => {
setShowMoveMenu(false);
setShowCreateGroupForMove(true);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left text-duck-teal"
>
<FolderPlus className="h-3.5 w-3.5" />
New Group
</button>
</div>
</div>
</>
)}
</div>
</div>
)}
{/* Delete confirmation dialog */}
{showDeleteConfirm && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) setShowDeleteConfirm(false);
}}
>
<div className="bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-lg shadow-xl w-full max-w-sm mx-4 p-6">
<h3 className="text-lg font-semibold text-duck-dark dark:text-foreground mb-2">Delete Sessions</h3>
<p className="text-sm text-duck-dark/60 dark:text-foreground/60 mb-6">
Are you sure you want to delete {selectedIds.size} session{selectedIds.size > 1 ? 's' : ''}? This action
cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isBulkDeleting}
className="px-4 py-2 text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 rounded-md transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleBulkDelete}
disabled={isBulkDeleting}
className="px-4 py-2 text-sm font-medium bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors disabled:opacity-50"
>
{isBulkDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{showCreateGroup && <CreateGroupDialog onClose={() => setShowCreateGroup(false)} />}
{showCreateGroupForMove && (
<CreateGroupDialog onClose={() => setShowCreateGroupForMove(false)} onCreateWithSessions={handleCreateGroupAndMove} />
)}
</div>
);
};
@@ -46,6 +46,19 @@ export function useChatSessions(filter?: ChatSessionsFilter) {
);
}
async function deleteSessions(sessionIds: string[]) {
await client.delete('/pi/sessions', {
sessionIds,
...(filter?.context ? { context: filter.context } : {}),
...(filter?.contextId ? { contextId: filter.contextId } : {}),
});
const idSet = new Set(sessionIds);
queryClient.setQueryData<SessionEntry[]>(
['PI_SESSIONS', filter?.context, filter?.contextId],
(prev) => prev?.filter((s) => !idSet.has(s.id)) ?? [],
);
}
async function clearSessions() {
await client.delete('/pi/sessions', {
...(filter?.context ? { context: filter.context } : {}),
@@ -68,6 +81,7 @@ export function useChatSessions(filter?: ChatSessionsFilter) {
saveMessages,
renameSession,
deleteSession,
deleteSessions,
clearSessions,
searchSessions,
invalidate,