From 495d9c63ffa4674a10e0c0bb1b46e8537fae66dd Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 17 Jul 2026 17:59:18 +0200 Subject: [PATCH] fix: proper drag&drop with dataTransfer, context menu (rename/delete/new), bold active session --- frontend/src/components/ai/SessionList.tsx | 184 ++++++++++++++++----- 1 file changed, 147 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/ai/SessionList.tsx b/frontend/src/components/ai/SessionList.tsx index abc8652..63aa25b 100644 --- a/frontend/src/components/ai/SessionList.tsx +++ b/frontend/src/components/ai/SessionList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import clsx from 'clsx'; import { fetchSessions, createSession, deleteSession, updateSession, @@ -19,6 +19,13 @@ interface TreeNode { children: TreeNode[]; } +interface ContextMenuState { + x: number; + y: number; + type: 'session' | 'folder' | 'root'; + id: string | null; +} + function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] { const rootSessions = sessions.filter((s) => !s.folder_id); const root: TreeNode = { folder: null, sessions: rootSessions, children: [] }; @@ -36,14 +43,13 @@ function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] { } function TreeItem({ - node, activeSessionId, onSelectSession, depth, onDeleteFolder, onAddFolder, onDragSession, onDropToFolder, + node, activeSessionId, onSelectSession, depth, onContextMenu, onDragSession, onDropToFolder, }: { node: TreeNode; activeSessionId: string | null; onSelectSession: (id: string) => void; depth: number; - onDeleteFolder: (folderId: string) => void; - onAddFolder: (parentId: string | null) => void; + onContextMenu: (e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => void; onDragSession: (sessionId: string) => void; onDropToFolder: (folderId: string | null) => void; }) { @@ -52,7 +58,6 @@ function TreeItem({ const isRoot = node.folder === null; const folderId = node.folder?.id || null; - // Drop handler for this specific folder - stopPropagation so parent doesn't also fire const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -63,23 +68,18 @@ function TreeItem({ const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setIsDragOver(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.stopPropagation(); - // Only clear if leaving to a non-child element - setIsDragOver(false); + if (!isRoot) setIsDragOver(true); }; return (
- {/* Folder header - this is the drop target */} + {/* Folder header - drop target */} {!isRoot && (
setIsDragOver(false)} onDrop={handleDrop} + onContextMenu={(e) => onContextMenu(e, 'folder', node.folder!.id)} className={clsx( 'group flex items-center gap-1 px-2 py-1.5 text-sm font-medium rounded-md cursor-pointer', isDragOver ? 'bg-primary-100 ring-2 ring-primary-400' : 'text-secondary-700 hover:bg-secondary-100' @@ -94,46 +94,51 @@ function TreeItem({ {node.folder!.name} - -
)} - {/* Sessions in this folder */} + {/* Sessions */} {(isRoot || expanded) && (
{node.sessions.map((session) => (
{ e.stopPropagation(); onDragSession(session.id); }} + onDragStart={(e) => { + e.dataTransfer.setData('text/plain', session.id); + e.dataTransfer.effectAllowed = 'move'; + onDragSession(session.id); + }} + onContextMenu={(e) => onContextMenu(e, 'session', session.id)} onClick={() => onSelectSession(session.id)} className={clsx( 'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md', 'hover:bg-secondary-100', - activeSessionId === session.id && 'bg-primary-100 text-primary-700' + activeSessionId === session.id && 'bg-primary-50' )} style={{ paddingLeft: depth * 12 + 24 }} > - {session.title} - + + {session.title} +
))}
)} - {/* Root drop zone - only for root level, separate from sessions */} + {/* Root drop zone */} {isRoot && (
setIsDragOver(false)} onDrop={handleDrop} + onContextMenu={(e) => onContextMenu(e, 'root', null)} className={clsx( - 'min-h-[8px] rounded-md transition-colors', - isDragOver && 'bg-primary-50 ring-1 ring-primary-300' + 'min-h-[12px] mt-1 rounded-md transition-colors', + isDragOver ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50' )} /> )} @@ -146,8 +151,7 @@ function TreeItem({ activeSessionId={activeSessionId} onSelectSession={onSelectSession} depth={depth + 1} - onDeleteFolder={onDeleteFolder} - onAddFolder={onAddFolder} + onContextMenu={onContextMenu} onDragSession={onDragSession} onDropToFolder={onDropToFolder} /> @@ -156,12 +160,69 @@ function TreeItem({ ); } +function ContextMenu({ + state, onClose, onRename, onDelete, onNewFolder, onNewChat, +}: { + state: ContextMenuState; + onClose: () => void; + onRename: (id: string, type: 'session' | 'folder') => void; + onDelete: (id: string, type: 'session' | 'folder') => void; + onNewFolder: () => void; + onNewChat: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onClose]); + + const items: { label: string; action: () => void; danger?: boolean }[] = []; + + if (state.type === 'session') { + items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'session'); onClose(); } }); + items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'session'); onClose(); }, danger: true }); + } else if (state.type === 'folder') { + items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'folder'); onClose(); } }); + items.push({ label: 'Neuer Unterordner', action: () => { onClose(); /* TODO */ } }); + items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'folder'); onClose(); }, danger: true }); + } else { + items.push({ label: 'Neuer Chat', action: () => { onNewChat(); onClose(); } }); + items.push({ label: 'Neuer Ordner', action: () => { onNewFolder(); onClose(); } }); + } + + return ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} + export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) { const [sessions, setSessions] = useState([]); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dragSessionId, setDragSessionId] = useState(null); + const [contextMenu, setContextMenu] = useState(null); const loadData = async () => { try { @@ -174,15 +235,50 @@ export function SessionList({ activeSessionId, onSelectSession, isSidebar, class useEffect(() => { loadData(); }, [isSidebar]); - const handleAddFolder = (parentId: string | null) => { - const name = prompt('Ordnername:'); - if (!name) return; - createFolder({ name, parent_id: parentId || undefined }).then(() => loadData()).catch((e) => setError(e?.message)); + const handleContextMenu = useCallback((e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, type, id }); + }, []); + + const handleRename = async (id: string, type: 'session' | 'folder') => { + const newName = prompt('Neuer Name:', type === 'session' + ? sessions.find(s => s.id === id)?.title || '' + : folders.find(f => f.id === id)?.name || '' + ); + if (!newName) return; + try { + if (type === 'session') await updateSession(id, { title: newName }); + else await updateFolder(id, { name: newName }); + loadData(); + } catch (e) { setError(e instanceof Error ? e.message : 'Rename failed'); } }; - const handleDeleteFolder = (folderId: string) => { - if (!confirm('Ordner löschen? Chats bleiben erhalten.')) return; - deleteFolder(folderId).then(() => loadData()).catch((e) => setError(e?.message)); + const handleDelete = async (id: string, type: 'session' | 'folder') => { + if (!confirm(type === 'session' ? 'Chat löschen?' : 'Ordner löschen? Chats bleiben erhalten.')) return; + try { + if (type === 'session') { + await deleteSession(id); + if (activeSessionId === id) onSelectSession(''); + } else { + await deleteFolder(id); + } + loadData(); + } catch (e) { setError(e instanceof Error ? e.message : 'Delete failed'); } + }; + + const handleNewFolder = () => { + const name = prompt('Ordnername:'); + if (!name) return; + createFolder({ name }).then(() => loadData()).catch((e) => setError(e?.message)); + }; + + const handleNewChat = async () => { + try { + const session = await createSession({ is_sidebar: isSidebar || false }); + onSelectSession(session.id); + loadData(); + } catch (e) { setError(e instanceof Error ? e.message : 'Failed'); } }; const handleDropToFolder = async (folderId: string | null) => { @@ -201,7 +297,12 @@ export function SessionList({ activeSessionId, onSelectSession, isSidebar, class return (
{error &&
{error}
} -
+
{ + if (e.target === e.currentTarget) handleContextMenu(e, 'root', null); + }} + > {tree.map((node) => ( ))}
+ {contextMenu && ( + setContextMenu(null)} + onRename={handleRename} + onDelete={handleDelete} + onNewFolder={handleNewFolder} + onNewChat={handleNewChat} + /> + )}
); }