diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 917dcd257f79..5341e292df26 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -7,8 +7,9 @@ import { Calendar, ChevronRight, Folder, + Trash2, } from 'lucide-react'; -import { fetchSessions, type Session } from '../../sessions'; +import { fetchSessions, type Session, deleteSession } from '../../sessions'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import BackButton from '../ui/BackButton'; @@ -18,6 +19,8 @@ import { formatMessageTimestamp } from '../../utils/timeUtils'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { SearchView } from '../conversation/SearchView'; import { SearchHighlighter } from '../../utils/searchHighlighter'; +import { toast } from 'react-toastify'; +import { Modal, ModalContent } from '../ui/modal'; interface SearchContainerElement extends HTMLDivElement { _searchHighlighter: SearchHighlighter | null; @@ -42,6 +45,8 @@ const SessionListView: React.FC = ({ setView, onSelectSess } | null>(null); const containerRef = useRef(null); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 }); + const [sessionToDelete, setSessionToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { loadSessions(); @@ -157,8 +162,37 @@ const SessionListView: React.FC = ({ setView, onSelectSess } }; + // Handle delete confirmation + const handleDeleteConfirm = async () => { + if (!sessionToDelete) return; + + setIsDeleting(true); + try { + await deleteSession(sessionToDelete.id); + + // Remove the deleted session from the lists + const updatedSessions = sessions.filter(session => session.id !== sessionToDelete.id); + setSessions(updatedSessions); + setFilteredSessions(filteredSessions.filter(session => session.id !== sessionToDelete.id)); + + toast.success(`Session "${sessionToDelete.metadata.description || sessionToDelete.id}" deleted successfully`); + } catch (err) { + console.error('Failed to delete session:', err); + toast.error('Failed to delete session. Please try again later.'); + } finally { + setIsDeleting(false); + setSessionToDelete(null); + } + }; + // Render a session item const SessionItem = React.memo(function SessionItem({ session }: { session: Session }) { + // Prevent event bubbling when clicking delete button + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setSessionToDelete(session); + }; + return ( onSelectSession(session.id)} @@ -199,6 +233,14 @@ const SessionListView: React.FC = ({ setView, onSelectSess )} + @@ -298,6 +340,57 @@ const SessionListView: React.FC = ({ setView, onSelectSess + + {/* Delete Confirmation Modal */} + !isDeleting && setSessionToDelete(null)}> + +
+ +
+ +
+

Delete Session

+
+ +
+

+ Are you sure you want to delete the session{' '} + + "{sessionToDelete?.metadata.description || sessionToDelete?.id}" + + ? This action cannot be undone. +

+
+ +
+ + +
+
+
); }; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 7696f6f74a12..7af0ead62c99 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1059,6 +1059,39 @@ ipcMain.handle('get-binary-path', (_event, binaryName) => { return getBinaryPath(app, binaryName); }); +ipcMain.handle('delete-session-file', async (_event, sessionId) => { + try { + // Validate sessionId format (should be a date-based ID like YYYYMMDD_HHMMSS) + if (!sessionId || typeof sessionId !== 'string' || !/^\d{8}_\d{6}$/.test(sessionId)) { + console.error(`Invalid session ID format: ${sessionId}`); + return false; + } + + // Get the sessions directory path + const sessionDir = path.join(app.getPath('home'), '.local', 'share', 'goose', 'sessions'); + const sessionFilePath = path.join(sessionDir, `${sessionId}.jsonl`); + + // Ensure the path is within the sessions directory (security check) + const resolvedPath = path.resolve(sessionFilePath); + const resolvedSessionDir = path.resolve(sessionDir); + if (!resolvedPath.startsWith(resolvedSessionDir + path.sep)) { + console.error(`Attempted path traversal detected: ${sessionFilePath}`); + return false; + } + + // Check if the file exists + await fs.access(sessionFilePath); + + // Delete the file + await fs.unlink(sessionFilePath); + console.log(`Session file deleted: ${sessionFilePath}`); + return true; + } catch (error) { + console.error(`Error deleting session file for ${sessionId}:`, error); + return false; + } +}); + ipcMain.handle('read-file', (_event, filePath) => { return new Promise((resolve) => { const cat = spawn('cat', [filePath]); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 13674f8827e4..d487a1a96c7f 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -56,6 +56,7 @@ type ElectronAPI = { getBinaryPath: (binaryName: string) => Promise; readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; + deleteSessionFile: (sessionId: string) => Promise; getAllowedExtensions: () => Promise; getPathForFile: (file: File) => string; setMenuBarIcon: (show: boolean) => Promise; @@ -119,6 +120,7 @@ const electronAPI: ElectronAPI = { readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), + deleteSessionFile: (sessionId: string) => ipcRenderer.invoke('delete-session-file', sessionId), getPathForFile: (file: File) => webUtils.getPathForFile(file), getAllowedExtensions: () => ipcRenderer.invoke('get-allowed-extensions'), setMenuBarIcon: (show: boolean) => ipcRenderer.invoke('set-menu-bar-icon', show), diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index b4ba4374b5d2..08f2862f5a2d 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -36,6 +36,21 @@ export interface SessionDetails { messages: Message[]; } +/** + * Deletes a session file + * @param sessionId The ID of the session to delete + * @returns Promise that resolves when the session is deleted + */ +export async function deleteSession(sessionId: string): Promise { + try { + // Use Electron IPC to delete the session file + await window.electron.deleteSessionFile(sessionId); + } catch (error) { + console.error(`Error deleting session ${sessionId}:`, error); + throw error; + } +} + /** * Generate a session ID in the format yyyymmdd_hhmmss */