diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index 0c64fffe..50a150e0 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -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) { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx index 764ef2f4..e61f4d0e 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx @@ -4,6 +4,7 @@ import { useChatGroups } from 'state/useChatGroups'; type CreateGroupDialogProps = { onClose: () => void; + onCreateWithSessions?: (name: string, slug: string, description?: string) => Promise; }; 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'); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index afe4d400..baca39e0 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -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('chat:selected-session', null); const [collapsed, setCollapsed] = useState>(new Set()); const [showCreateGroup, setShowCreateGroup] = useState(false); + // Selection mode state + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(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 (
+ {selectionMode && ( +
e.stopPropagation()}> + toggleSelection(session.id)} + className="border-duck-dark/30 dark:border-foreground/30 data-[state=checked]:bg-duck-teal data-[state=checked]:border-duck-teal" + /> +
+ )} -
- -
+ {!selectionMode && ( +
+ +
+ )}
); }; @@ -133,23 +237,51 @@ export const SessionList = () => {

Sessions

- - + {selectionMode ? ( + <> + + + + ) : ( + <> + {sessions.length > 0 && ( + + )} + + + + )}
@@ -164,6 +296,14 @@ export const SessionList = () => { {/* Ungrouped sessions */} {ungrouped.length > 0 && (
+ {selectionMode && ungrouped.length > 1 && ( + + )} {ungrouped.map(renderSession)}
)} @@ -172,6 +312,15 @@ export const SessionList = () => { {grouped.map((group) => (
+ {selectionMode && group.sessions.length > 0 && ( +
e.stopPropagation()}> + 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" + /> +
+ )}
toggleGroup(group.slug)} className="flex items-center gap-2 flex-1 min-w-0"> { {group.name} ({group.sessionCount})
- + {!selectionMode && }
{!collapsed.has(group.slug) && (
@@ -191,7 +340,106 @@ export const SessionList = () => { ))}
+ {/* Floating action bar */} + {selectionMode && selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + +
+ + + {showMoveMenu && ( + <> +
setShowMoveMenu(false)} /> +
+
+ Move to: +
+ + {groups.map((group) => ( + + ))} +
+ +
+
+ + )} +
+
+ )} + + {/* Delete confirmation dialog */} + {showDeleteConfirm && ( +
{ + if (e.target === e.currentTarget) setShowDeleteConfirm(false); + }} + > +
+

Delete Sessions

+

+ Are you sure you want to delete {selectedIds.size} session{selectedIds.size > 1 ? 's' : ''}? This action + cannot be undone. +

+
+ + +
+
+
+ )} + {showCreateGroup && setShowCreateGroup(false)} />} + {showCreateGroupForMove && ( + setShowCreateGroupForMove(false)} onCreateWithSessions={handleCreateGroupAndMove} /> + )}
); }; diff --git a/src/workspaces/state/src/useChatSessions.ts b/src/workspaces/state/src/useChatSessions.ts index b28c80b1..b7b67ae2 100644 --- a/src/workspaces/state/src/useChatSessions.ts +++ b/src/workspaces/state/src/useChatSessions.ts @@ -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( + ['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,