diff --git a/apps/frontend/src/main/insights-service.ts b/apps/frontend/src/main/insights-service.ts index 726cfcbb26..3cb16721b7 100644 --- a/apps/frontend/src/main/insights-service.ts +++ b/apps/frontend/src/main/insights-service.ts @@ -70,8 +70,8 @@ export class InsightsService extends EventEmitter { /** * List all sessions for a project */ - listSessions(projectPath: string): InsightsSessionSummary[] { - return this.sessionManager.listSessions(projectPath); + listSessions(projectPath: string, includeArchived = false): InsightsSessionSummary[] { + return this.sessionManager.listSessions(projectPath, includeArchived); } /** @@ -95,6 +95,34 @@ export class InsightsService extends EventEmitter { return this.sessionManager.deleteSession(projectId, projectPath, sessionId); } + /** + * Archive a session + */ + archiveSession(projectId: string, projectPath: string, sessionId: string): boolean { + return this.sessionManager.archiveSession(projectId, projectPath, sessionId); + } + + /** + * Unarchive a session + */ + unarchiveSession(projectPath: string, sessionId: string): boolean { + return this.sessionManager.unarchiveSession(projectPath, sessionId); + } + + /** + * Delete multiple sessions + */ + deleteSessions(projectId: string, projectPath: string, sessionIds: string[]): { deletedIds: string[]; failedIds: string[] } { + return this.sessionManager.deleteSessions(projectId, projectPath, sessionIds); + } + + /** + * Archive multiple sessions + */ + archiveSessions(projectId: string, projectPath: string, sessionIds: string[]): { archivedIds: string[]; failedIds: string[] } { + return this.sessionManager.archiveSessions(projectId, projectPath, sessionIds); + } + /** * Rename a session */ diff --git a/apps/frontend/src/main/insights/paths.ts b/apps/frontend/src/main/insights/paths.ts index 056ab7618a..b587a2cea0 100644 --- a/apps/frontend/src/main/insights/paths.ts +++ b/apps/frontend/src/main/insights/paths.ts @@ -23,10 +23,21 @@ export class InsightsPaths { return path.join(this.getInsightsDir(projectPath), SESSIONS_DIR); } + /** + * Validate that a session ID matches the expected safe pattern. + * Prevents path traversal attacks via crafted session IDs. + */ + private validateSessionId(sessionId: string): void { + if (!/^session-\d{1,20}$/.test(sessionId)) { + throw new Error(`Invalid session ID format: ${sessionId}`); + } + } + /** * Get session file path for a specific session */ getSessionPath(projectPath: string, sessionId: string): string { + this.validateSessionId(sessionId); return path.join(this.getSessionsDir(projectPath), `${sessionId}.json`); } diff --git a/apps/frontend/src/main/insights/session-manager.ts b/apps/frontend/src/main/insights/session-manager.ts index 73624a57da..83ad5429a9 100644 --- a/apps/frontend/src/main/insights/session-manager.ts +++ b/apps/frontend/src/main/insights/session-manager.ts @@ -20,8 +20,9 @@ export class SessionManager { */ loadSession(projectId: string, projectPath: string): InsightsSession | null { // Check in-memory cache first - if (this.sessions.has(projectId)) { - return this.sessions.get(projectId)!; + const cachedSession = this.sessions.get(projectId); + if (cachedSession) { + return cachedSession; } // Migrate old format if needed @@ -40,10 +41,10 @@ export class SessionManager { /** * List all sessions for a project */ - listSessions(projectPath: string): InsightsSessionSummary[] { + listSessions(projectPath: string, includeArchived = false): InsightsSessionSummary[] { // Migrate old format if needed this.storage.migrateOldSession(projectPath); - return this.storage.listSessions(projectPath); + return this.storage.listSessions(projectPath, includeArchived); } /** @@ -105,6 +106,80 @@ export class SessionManager { return true; } + /** + * Archive a session + */ + archiveSession(projectId: string, projectPath: string, sessionId: string): boolean { + const success = this.storage.archiveSession(projectPath, sessionId); + if (!success) return false; + + // If this was the current session, auto-switch + const currentSession = this.sessions.get(projectId); + if (currentSession?.id === sessionId) { + this.sessions.delete(projectId); + + const remaining = this.listSessions(projectPath); + if (remaining.length > 0) { + this.switchSession(projectId, projectPath, remaining[0].id); + } else { + this.storage.clearCurrentSessionId(projectPath); + } + } + + return true; + } + + /** + * Unarchive a session + */ + unarchiveSession(projectPath: string, sessionId: string): boolean { + return this.storage.unarchiveSession(projectPath, sessionId); + } + + /** + * Delete multiple sessions + */ + deleteSessions(projectId: string, projectPath: string, sessionIds: string[]): { deletedIds: string[]; failedIds: string[] } { + const result = this.storage.deleteSessions(projectPath, sessionIds); + + // Check if current cached session was among deleted + const currentSession = this.sessions.get(projectId); + if (currentSession && result.deletedIds.includes(currentSession.id)) { + this.sessions.delete(projectId); + + const remaining = this.listSessions(projectPath); + if (remaining.length > 0) { + this.switchSession(projectId, projectPath, remaining[0].id); + } else { + this.storage.clearCurrentSessionId(projectPath); + } + } + + return result; + } + + /** + * Archive multiple sessions + */ + archiveSessions(projectId: string, projectPath: string, sessionIds: string[]): { archivedIds: string[]; failedIds: string[] } { + const result = this.storage.archiveSessions(projectPath, sessionIds); + + // Check if current cached session was among archived + const currentSession = this.sessions.get(projectId); + if (currentSession && result.archivedIds.includes(currentSession.id)) { + this.sessions.delete(projectId); + + const remaining = this.listSessions(projectPath); + if (remaining.length > 0) { + this.switchSession(projectId, projectPath, remaining[0].id); + } else { + this.storage.clearCurrentSessionId(projectPath); + } + } + + return result; + } + /** * Rename a session */ diff --git a/apps/frontend/src/main/insights/session-storage.ts b/apps/frontend/src/main/insights/session-storage.ts index d4455b0319..c191a3319e 100644 --- a/apps/frontend/src/main/insights/session-storage.ts +++ b/apps/frontend/src/main/insights/session-storage.ts @@ -36,6 +36,9 @@ export class SessionStorage { // Convert date strings back to Date objects session.createdAt = new Date(session.createdAt); session.updatedAt = new Date(session.updatedAt); + if (session.archivedAt) { + session.archivedAt = new Date(session.archivedAt); + } session.messages = session.messages.map(m => ({ ...m, timestamp: new Date(m.timestamp), @@ -64,6 +67,76 @@ export class SessionStorage { writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf-8'); } + /** + * Archive a session + */ + archiveSession(projectPath: string, sessionId: string): boolean { + const session = this.loadSessionById(projectPath, sessionId); + if (!session) return false; + + try { + session.archivedAt = new Date(); + this.saveSession(projectPath, session); + return true; + } catch (error) { + console.error(`[SessionStorage] Failed to archive session ${sessionId}:`, error); + return false; + } + } + + /** + * Unarchive a session + */ + unarchiveSession(projectPath: string, sessionId: string): boolean { + const session = this.loadSessionById(projectPath, sessionId); + if (!session) return false; + + try { + delete session.archivedAt; + this.saveSession(projectPath, session); + return true; + } catch (error) { + console.error(`[SessionStorage] Failed to unarchive session ${sessionId}:`, error); + return false; + } + } + + /** + * Delete multiple sessions + */ + deleteSessions(projectPath: string, sessionIds: string[]): { deletedIds: string[]; failedIds: string[] } { + const deletedIds: string[] = []; + const failedIds: string[] = []; + + for (const sessionId of sessionIds) { + if (this.deleteSession(projectPath, sessionId)) { + deletedIds.push(sessionId); + } else { + failedIds.push(sessionId); + } + } + + return { deletedIds, failedIds }; + } + + /** + * Archive multiple sessions + */ + archiveSessions(projectPath: string, sessionIds: string[]): { archivedIds: string[]; failedIds: string[] } { + const archivedIds: string[] = []; + const failedIds: string[] = []; + + for (const sessionId of sessionIds) { + if (this.archiveSession(projectPath, sessionId)) { + archivedIds.push(sessionId); + } else { + failedIds.push(sessionId); + } + } + + return { archivedIds, failedIds }; + } + /** * Delete a session from disk */ @@ -82,7 +155,7 @@ export class SessionStorage { /** * List all sessions for a project */ - listSessions(projectPath: string): InsightsSessionSummary[] { + listSessions(projectPath: string, includeArchived = false): InsightsSessionSummary[] { const sessionsDir = this.paths.getSessionsDir(projectPath); if (!existsSync(sessionsDir)) return []; @@ -104,13 +177,20 @@ export class SessionStorage { : 'Untitled Conversation'; } + // Skip archived sessions unless explicitly included + if (!includeArchived && session.archivedAt) { + continue; + } + sessions.push({ id: session.id, projectId: session.projectId, title: title || 'New Conversation', messageCount: session.messages.length, + modelConfig: session.modelConfig, createdAt: new Date(session.createdAt), - updatedAt: new Date(session.updatedAt) + updatedAt: new Date(session.updatedAt), + ...(session.archivedAt ? { archivedAt: new Date(session.archivedAt) } : {}) }); } catch { // Skip invalid session files diff --git a/apps/frontend/src/main/ipc-handlers/insights-handlers.ts b/apps/frontend/src/main/ipc-handlers/insights-handlers.ts index dd951418d6..32fcaca05c 100644 --- a/apps/frontend/src/main/ipc-handlers/insights-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/insights-handlers.ts @@ -249,17 +249,87 @@ export function registerInsightsHandlers(getMainWindow: () => BrowserWindow | nu // List all sessions for a project ipcMain.handle( IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, - async (_, projectId: string): Promise> => { + async (_, projectId: string, includeArchived?: boolean): Promise> => { const project = projectStore.getProject(projectId); if (!project) { return { success: false, error: "Project not found" }; } - const sessions = insightsService.listSessions(project.path); + const sessions = insightsService.listSessions(project.path, includeArchived ?? false); return { success: true, data: sessions }; } ); + // Delete multiple sessions + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_DELETE_SESSIONS, + async (_, projectId: string, sessionIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const result = insightsService.deleteSessions(projectId, project.path, sessionIds); + return { + success: result.failedIds.length === 0, + data: result, + ...(result.failedIds.length > 0 && { error: `Failed to delete ${result.failedIds.length} session(s)` }) + }; + } + ); + + // Archive a session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_ARCHIVE_SESSION, + async (_, projectId: string, sessionId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const success = insightsService.archiveSession(projectId, project.path, sessionId); + if (success) { + return { success: true }; + } + return { success: false, error: "Failed to archive session" }; + } + ); + + // Archive multiple sessions + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_ARCHIVE_SESSIONS, + async (_, projectId: string, sessionIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const result = insightsService.archiveSessions(projectId, project.path, sessionIds); + return { + success: result.failedIds.length === 0, + data: result, + ...(result.failedIds.length > 0 && { error: `Failed to archive ${result.failedIds.length} session(s)` }) + }; + } + ); + + // Unarchive a session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_UNARCHIVE_SESSION, + async (_, projectId: string, sessionId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const success = insightsService.unarchiveSession(project.path, sessionId); + if (success) { + return { success: true }; + } + return { success: false, error: "Failed to unarchive session" }; + } + ); + // Create a new session ipcMain.handle( IPC_CHANNELS.INSIGHTS_NEW_SESSION, diff --git a/apps/frontend/src/preload/api/modules/insights-api.ts b/apps/frontend/src/preload/api/modules/insights-api.ts index f65d60c282..69d0c28277 100644 --- a/apps/frontend/src/preload/api/modules/insights-api.ts +++ b/apps/frontend/src/preload/api/modules/insights-api.ts @@ -25,10 +25,14 @@ export interface InsightsAPI { description: string, metadata?: TaskMetadata ) => Promise>; - listInsightsSessions: (projectId: string) => Promise>; + listInsightsSessions: (projectId: string, includeArchived?: boolean) => Promise>; newInsightsSession: (projectId: string) => Promise>; switchInsightsSession: (projectId: string, sessionId: string) => Promise>; deleteInsightsSession: (projectId: string, sessionId: string) => Promise; + deleteInsightsSessions: (projectId: string, sessionIds: string[]) => Promise>; + archiveInsightsSession: (projectId: string, sessionId: string) => Promise; + archiveInsightsSessions: (projectId: string, sessionIds: string[]) => Promise>; + unarchiveInsightsSession: (projectId: string, sessionId: string) => Promise; renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise; updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise; @@ -69,8 +73,8 @@ export const createInsightsAPI = (): InsightsAPI => ({ ): Promise> => invokeIpc(IPC_CHANNELS.INSIGHTS_CREATE_TASK, projectId, title, description, metadata), - listInsightsSessions: (projectId: string): Promise> => - invokeIpc(IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, projectId), + listInsightsSessions: (projectId: string, includeArchived?: boolean): Promise> => + invokeIpc(IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, projectId, includeArchived), newInsightsSession: (projectId: string): Promise> => invokeIpc(IPC_CHANNELS.INSIGHTS_NEW_SESSION, projectId), @@ -81,6 +85,18 @@ export const createInsightsAPI = (): InsightsAPI => ({ deleteInsightsSession: (projectId: string, sessionId: string): Promise => invokeIpc(IPC_CHANNELS.INSIGHTS_DELETE_SESSION, projectId, sessionId), + deleteInsightsSessions: (projectId: string, sessionIds: string[]): Promise> => + invokeIpc(IPC_CHANNELS.INSIGHTS_DELETE_SESSIONS, projectId, sessionIds), + + archiveInsightsSession: (projectId: string, sessionId: string): Promise => + invokeIpc(IPC_CHANNELS.INSIGHTS_ARCHIVE_SESSION, projectId, sessionId), + + archiveInsightsSessions: (projectId: string, sessionIds: string[]): Promise> => + invokeIpc(IPC_CHANNELS.INSIGHTS_ARCHIVE_SESSIONS, projectId, sessionIds), + + unarchiveInsightsSession: (projectId: string, sessionId: string): Promise => + invokeIpc(IPC_CHANNELS.INSIGHTS_UNARCHIVE_SESSION, projectId, sessionId), + renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise => invokeIpc(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle), diff --git a/apps/frontend/src/renderer/components/ChatHistorySidebar.tsx b/apps/frontend/src/renderer/components/ChatHistorySidebar.tsx index 2d6e91080c..4e9e1371c0 100644 --- a/apps/frontend/src/renderer/components/ChatHistorySidebar.tsx +++ b/apps/frontend/src/renderer/components/ChatHistorySidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Plus, @@ -8,11 +8,15 @@ import { Check, X, MoreVertical, - Loader2 + Loader2, + CheckSquare, + Archive, + ArchiveRestore } from 'lucide-react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { ScrollArea } from './ui/scroll-area'; +import { Checkbox } from './ui/checkbox'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { DropdownMenu, @@ -41,6 +45,12 @@ interface ChatHistorySidebarProps { onSelectSession: (sessionId: string) => void; onDeleteSession: (sessionId: string) => Promise; onRenameSession: (sessionId: string, newTitle: string) => Promise; + onArchiveSession?: (sessionId: string) => Promise; + onUnarchiveSession?: (sessionId: string) => Promise; + onDeleteSessions?: (sessionIds: string[]) => Promise; + onArchiveSessions?: (sessionIds: string[]) => Promise; + showArchived?: boolean; + onToggleShowArchived?: () => void; } export function ChatHistorySidebar({ @@ -50,12 +60,63 @@ export function ChatHistorySidebar({ onNewSession, onSelectSession, onDeleteSession, - onRenameSession + onRenameSession, + onArchiveSession, + onUnarchiveSession, + onDeleteSessions, + onArchiveSessions, + showArchived = false, + onToggleShowArchived }: ChatHistorySidebarProps) { const { t } = useTranslation('common'); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [deleteSessionId, setDeleteSessionId] = useState(null); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + + // Clear selection when exiting selection mode + const handleToggleSelectionMode = useCallback(() => { + setIsSelectionMode((prev) => { + if (prev) { + setSelectedIds(new Set()); + } + return !prev; + }); + }, []); + + // Prune selectedIds when sessions change - removes IDs for sessions no longer displayed + // Also resets when showArchived toggles + // biome-ignore lint/correctness/useExhaustiveDependencies: showArchived is intentionally a dependency to reset selection on filter change + useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const validIds = new Set(sessions.map((s) => s.id)); + const pruned = new Set([...prev].filter((id) => validIds.has(id))); + return pruned.size === prev.size ? prev : pruned; + }); + }, [sessions, showArchived]); + + const handleToggleSelect = useCallback((sessionId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + setSelectedIds(new Set(sessions.map((s) => s.id))); + }, [sessions]); + + const handleClearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); const handleStartEdit = (session: InsightsSessionSummary) => { setEditingId(session.id); @@ -82,6 +143,29 @@ export function ChatHistorySidebar({ } }; + const handleBulkDelete = async () => { + if (selectedIds.size > 0 && onDeleteSessions) { + try { + await onDeleteSessions(Array.from(selectedIds)); + setSelectedIds(new Set()); + setBulkDeleteOpen(false); + } catch (error) { + console.error('Failed to delete sessions:', error); + } + } + }; + + const handleBulkArchive = async () => { + if (selectedIds.size > 0 && onArchiveSessions) { + try { + await onArchiveSessions(Array.from(selectedIds)); + setSelectedIds(new Set()); + } catch (error) { + console.error('Failed to archive sessions:', error); + } + } + }; + const formatDate = (date: Date) => { const now = new Date(); const d = new Date(date); @@ -109,27 +193,90 @@ export function ChatHistorySidebar({ return groups; }, {} as Record); + // Sessions selected for bulk delete preview + const sessionsToDelete = sessions.filter((s) => selectedIds.has(s.id)); + return (
{/* Header */}
-

Chat History

- - - - - {t('accessibility.newConversationAriaLabel')} - +

{t('insights.chatHistory')}

+
+ {/* Selection mode toggle */} + + + + + + {isSelectionMode ? t('insights.exitSelectMode') : t('insights.selectMode')} + + + + {/* Show archived toggle */} + {onToggleShowArchived && ( + + + + + + {showArchived ? t('insights.hideArchived') : t('insights.showArchived')} + + + )} + + + + + + {t('accessibility.newConversationAriaLabel')} + +
+ {/* Select All / Clear links */} + {isSelectionMode && sessions.length > 0 && ( +
+ + +
+ )} + {/* Session list */} {isLoading ? ( @@ -138,7 +285,7 @@ export function ChatHistorySidebar({
) : sessions.length === 0 ? (
- No conversations yet + {t('insights.noConversations')}
) : (
@@ -160,6 +307,12 @@ export function ChatHistorySidebar({ onCancelEdit={handleCancelEdit} onEditTitleChange={setEditTitle} onDelete={() => setDeleteSessionId(session.id)} + onArchive={onArchiveSession ? () => onArchiveSession(session.id).catch((e) => console.error('Archive failed:', e)) : undefined} + onUnarchive={onUnarchiveSession ? () => onUnarchiveSession(session.id).catch((e) => console.error('Unarchive failed:', e)) : undefined} + isArchived={!!session.archivedAt} + isSelectionMode={isSelectionMode} + isSelected={selectedIds.has(session.id)} + onToggleSelect={() => handleToggleSelect(session.id)} /> ))}
@@ -168,19 +321,76 @@ export function ChatHistorySidebar({ )} - {/* Delete confirmation dialog */} + {/* Bulk action toolbar */} + {isSelectionMode && selectedIds.size > 0 && ( +
+ + {onArchiveSessions && ( + + )} +
+ )} + + {/* Single delete confirmation dialog */} setDeleteSessionId(null)}> - Delete conversation? + {t('insights.bulkDeleteTitle')} - This will permanently delete this conversation and all its messages. - This action cannot be undone. + {t('insights.bulkDeleteDescription', { count: 1 })} - Cancel - Delete + {t('actions.cancel')} + {t('actions.delete')} + + + + + {/* Bulk delete confirmation dialog */} + + + + {t('insights.bulkDeleteTitle')} + + {t('insights.bulkDeleteDescription', { count: selectedIds.size })} + + + {sessionsToDelete.length > 0 && ( +
+

+ {t('insights.conversationsToDelete')}: +

+
    + {sessionsToDelete.map((s) => ( +
  • + {s.title} +
  • + ))} +
+
+ )} + + {t('actions.cancel')} + + {t('insights.bulkDeleteConfirm', { count: selectedIds.size })} +
@@ -199,6 +409,12 @@ interface SessionItemProps { onCancelEdit: () => void; onEditTitleChange: (title: string) => void; onDelete: () => void; + onArchive?: () => Promise; + onUnarchive?: () => Promise; + isArchived: boolean; + isSelectionMode: boolean; + isSelected: boolean; + onToggleSelect: () => void; } function SessionItem({ @@ -211,7 +427,13 @@ function SessionItem({ onSaveEdit, onCancelEdit, onEditTitleChange, - onDelete + onDelete, + onArchive, + onUnarchive, + isArchived, + isSelectionMode, + isSelected, + onToggleSelect }: SessionItemProps) { const { t } = useTranslation('common'); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -257,61 +479,105 @@ function SessionItem({ return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + isSelectionMode ? onToggleSelect() : onSelect(); + } + }} > {/* Content with reserved space for the menu button */}
- -
-

+ +

+ ) : ( + - {session.title} -

+ /> + )} +
+
+

+ {session.title} +

+ {isArchived && ( + + + {t('insights.archived')} + + )} +

{session.messageCount} message{session.messageCount !== 1 ? 's' : ''}

- {/* Absolutely positioned menu button - always visible */} - - e.stopPropagation()}> - - - - - - Rename - - - - Delete - - - + {/* Absolutely positioned menu button - hidden in selection mode */} + {!isSelectionMode && ( + + e.stopPropagation()}> + + + + + + {t('accessibility.renameAriaLabel')} + + {isArchived ? ( + onUnarchive && ( + + + {t('insights.unarchive')} + + ) + ) : ( + onArchive && ( + + + {t('insights.archive')} + + ) + )} + + + {t('accessibility.deleteAriaLabel')} + + + + )}
); } diff --git a/apps/frontend/src/renderer/components/Insights.tsx b/apps/frontend/src/renderer/components/Insights.tsx index ec5e1e61e7..026887bc8d 100644 --- a/apps/frontend/src/renderer/components/Insights.tsx +++ b/apps/frontend/src/renderer/components/Insights.tsx @@ -31,10 +31,15 @@ import { newSession, switchSession, deleteSession, + deleteSessions, renameSession, + archiveSession, + archiveSessions, + unarchiveSession, updateModelConfig, createTaskFromSuggestion, - setupInsightsListeners + setupInsightsListeners, + loadInsightsSessions } from '../stores/insights-store'; import { loadTasks } from '../stores/task-store'; import { ChatHistorySidebar } from './ChatHistorySidebar'; @@ -105,6 +110,7 @@ export function Insights({ projectId }: InsightsProps) { const [creatingTask, setCreatingTask] = useState>(new Set()); const [taskCreated, setTaskCreated] = useState>(new Set()); const [showSidebar, setShowSidebar] = useState(true); + const [showArchived, setShowArchived] = useState(false); const [isUserAtBottom, setIsUserAtBottom] = useState(true); const [viewportEl, setViewportEl] = useState(null); @@ -143,6 +149,20 @@ export function Insights({ projectId }: InsightsProps) { return cleanup; }, [projectId]); + // Reload sessions when showArchived changes (skip first run to avoid duplicate load) + const isFirstRun = useRef(true); + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset isFirstRun when projectId changes so the initial load from the first effect handles it + useEffect(() => { + isFirstRun.current = true; + }, [projectId]); + useEffect(() => { + if (isFirstRun.current) { + isFirstRun.current = false; + return; + } + loadInsightsSessions(projectId, showArchived); + }, [projectId, showArchived]); + // Smart auto-scroll: only scroll if user is already at bottom // This allows users to scroll up to read previous messages without being // yanked back down during streaming responses @@ -199,6 +219,64 @@ export function Insights({ projectId }: InsightsProps) { return await renameSession(projectId, sessionId, newTitle); }; + const handleArchiveSession = async (sessionId: string) => { + try { + await archiveSession(projectId, sessionId); + await loadInsightsSessions(projectId, showArchived); + // Reload current session in case backend switched to a different one + await loadInsightsSession(projectId); + } catch (error) { + console.error(`Failed to archive session ${sessionId}:`, error); + } + }; + + const handleUnarchiveSession = async (sessionId: string) => { + try { + await unarchiveSession(projectId, sessionId); + await loadInsightsSessions(projectId, showArchived); + // Reload current session in case backend switched to a different one + await loadInsightsSession(projectId); + } catch (error) { + console.error(`Failed to unarchive session ${sessionId}:`, error); + } + }; + + const handleDeleteSessions = async (sessionIds: string[]) => { + try { + const result = await deleteSessions(projectId, sessionIds); + await loadInsightsSessions(projectId, showArchived); + // Reload current session in case backend switched to a different one + await loadInsightsSession(projectId); + + // Log partial failures for debugging + if (result.failedIds && result.failedIds.length > 0) { + console.warn(`Failed to delete ${result.failedIds.length} session(s):`, result.failedIds); + } + } catch (error) { + console.error(`Failed to delete sessions ${sessionIds.join(', ')}:`, error); + } + }; + + const handleArchiveSessions = async (sessionIds: string[]) => { + try { + const result = await archiveSessions(projectId, sessionIds); + await loadInsightsSessions(projectId, showArchived); + // Reload current session in case backend switched to a different one + await loadInsightsSession(projectId); + + // Log partial failures for debugging + if (result.failedIds && result.failedIds.length > 0) { + console.warn(`Failed to archive ${result.failedIds.length} session(s):`, result.failedIds); + } + } catch (error) { + console.error(`Failed to archive sessions ${sessionIds.join(', ')}:`, error); + } + }; + + const handleToggleShowArchived = () => { + setShowArchived(prev => !prev); + }; + const handleCreateTask = async ( messageId: string, taskIndex: number, @@ -250,6 +328,12 @@ export function Insights({ projectId }: InsightsProps) { onSelectSession={handleSelectSession} onDeleteSession={handleDeleteSession} onRenameSession={handleRenameSession} + onArchiveSession={handleArchiveSession} + onUnarchiveSession={handleUnarchiveSession} + onDeleteSessions={handleDeleteSessions} + onArchiveSessions={handleArchiveSessions} + showArchived={showArchived} + onToggleShowArchived={handleToggleShowArchived} /> )} diff --git a/apps/frontend/src/renderer/lib/mocks/insights-mock.ts b/apps/frontend/src/renderer/lib/mocks/insights-mock.ts index 8f619f0c03..254f3c8959 100644 --- a/apps/frontend/src/renderer/lib/mocks/insights-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/insights-mock.ts @@ -16,7 +16,7 @@ export const insightsMock = { } : null }), - listInsightsSessions: async () => ({ + listInsightsSessions: async (_projectId?: string, _includeArchived?: boolean) => ({ success: true, data: mockInsightsSessions }), @@ -69,6 +69,28 @@ export const insightsMock = { return { success: true }; }, + deleteInsightsSessions: async (_projectId: string, sessionIds: string[]) => { + for (const sessionId of sessionIds) { + const index = mockInsightsSessions.findIndex(s => s.id === sessionId); + if (index !== -1) { + mockInsightsSessions.splice(index, 1); + } + } + return { success: true, data: { deletedIds: sessionIds, failedIds: [] } }; + }, + + archiveInsightsSession: async (_projectId: string, _sessionId: string) => { + return { success: true }; + }, + + archiveInsightsSessions: async (_projectId: string, sessionIds: string[]) => { + return { success: true, data: { archivedIds: sessionIds, failedIds: [] } }; + }, + + unarchiveInsightsSession: async (_projectId: string, _sessionId: string) => { + return { success: true }; + }, + renameInsightsSession: async (_projectId: string, sessionId: string, newTitle: string) => { const session = mockInsightsSessions.find(s => s.id === sessionId); if (session) { diff --git a/apps/frontend/src/renderer/stores/insights-store.ts b/apps/frontend/src/renderer/stores/insights-store.ts index 445647b948..70b3183ff9 100644 --- a/apps/frontend/src/renderer/stores/insights-store.ts +++ b/apps/frontend/src/renderer/stores/insights-store.ts @@ -207,12 +207,12 @@ export const useInsightsStore = create((set, _get) => ({ // Helper functions -export async function loadInsightsSessions(projectId: string): Promise { +export async function loadInsightsSessions(projectId: string, includeArchived?: boolean): Promise { const store = useInsightsStore.getState(); store.setLoadingSessions(true); try { - const result = await window.electronAPI.listInsightsSessions(projectId); + const result = await window.electronAPI.listInsightsSessions(projectId, includeArchived); if (result.success && result.data) { store.setSessions(result.data); } else { @@ -313,6 +313,32 @@ export async function renameSession(projectId: string, sessionId: string, newTit return false; } +export async function deleteSessions(projectId: string, sessionIds: string[]): Promise<{ success: boolean; failedIds?: string[] }> { + const result = await window.electronAPI.deleteInsightsSessions(projectId, sessionIds); + if (result.success) { + return { success: true, failedIds: result.data?.failedIds }; + } + return { success: false, failedIds: result.data?.failedIds }; +} + +export async function archiveSession(projectId: string, sessionId: string): Promise { + const result = await window.electronAPI.archiveInsightsSession(projectId, sessionId); + return result.success; +} + +export async function archiveSessions(projectId: string, sessionIds: string[]): Promise<{ success: boolean; failedIds?: string[] }> { + const result = await window.electronAPI.archiveInsightsSessions(projectId, sessionIds); + if (result.success) { + return { success: true, failedIds: result.data?.failedIds }; + } + return { success: false, failedIds: result.data?.failedIds }; +} + +export async function unarchiveSession(projectId: string, sessionId: string): Promise { + const result = await window.electronAPI.unarchiveInsightsSession(projectId, sessionId); + return result.success; +} + export async function updateModelConfig(projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise { const result = await window.electronAPI.updateInsightsModelConfig(projectId, sessionId, modelConfig); if (result.success) { diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 4c62860856..809b09b5fd 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -496,6 +496,10 @@ export const IPC_CHANNELS = { INSIGHTS_NEW_SESSION: 'insights:newSession', INSIGHTS_SWITCH_SESSION: 'insights:switchSession', INSIGHTS_DELETE_SESSION: 'insights:deleteSession', + INSIGHTS_DELETE_SESSIONS: 'insights:deleteSessions', + INSIGHTS_ARCHIVE_SESSION: 'insights:archiveSession', + INSIGHTS_ARCHIVE_SESSIONS: 'insights:archiveSessions', + INSIGHTS_UNARCHIVE_SESSION: 'insights:unarchiveSession', INSIGHTS_RENAME_SESSION: 'insights:renameSession', INSIGHTS_UPDATE_MODEL_CONFIG: 'insights:updateModelConfig', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index f504232db0..67cde466c1 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -116,6 +116,7 @@ "selectAll": "Select All", "clearSelection": "Clear Selection", "deleteSelected": "Delete Selected", + "archiveSelected": "Archive Selected", "selectedOfTotal": "{{selected}} of {{total}} selected" }, "time": { @@ -429,7 +430,22 @@ "suggestedTask": "Suggested Task", "creating": "Creating...", "taskCreated": "Task Created", - "createTask": "Create Task" + "createTask": "Create Task", + "chatHistory": "Chat History", + "archive": "Archive", + "unarchive": "Unarchive", + "archiveSelected": "Archive Selected", + "showArchived": "Show Archived", + "hideArchived": "Hide Archived", + "bulkDeleteTitle": "Delete Conversations", + "bulkDeleteDescription": "Are you sure you want to delete {{count}} conversation(s)? This action cannot be undone.", + "bulkDeleteConfirm": "Delete {{count}} Conversation(s)", + "noConversations": "No conversations", + "archived": "Archived", + "conversationsToDelete": "Conversations to delete", + "archiveConfirmDescription": "Are you sure you want to archive the selected conversations?", + "selectMode": "Select", + "exitSelectMode": "Done" }, "ideation": { "converting": "Converting...", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index b1ec2c6f8a..ab26d30f35 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -116,6 +116,7 @@ "selectAll": "Tout sélectionner", "clearSelection": "Effacer la sélection", "deleteSelected": "Supprimer la sélection", + "archiveSelected": "Archiver la sélection", "selectedOfTotal": "{{selected}} sur {{total}} sélectionné(s)" }, "time": { @@ -429,7 +430,22 @@ "suggestedTask": "Tâche suggérée", "creating": "Création...", "taskCreated": "Tâche créée", - "createTask": "Créer une tâche" + "createTask": "Créer une tâche", + "chatHistory": "Historique des conversations", + "archive": "Archiver", + "unarchive": "Désarchiver", + "archiveSelected": "Archiver la sélection", + "showArchived": "Afficher les archivées", + "hideArchived": "Masquer les archivées", + "bulkDeleteTitle": "Supprimer les conversations", + "bulkDeleteDescription": "Êtes-vous sûr de vouloir supprimer {{count}} conversation(s) ? Cette action est irréversible.", + "bulkDeleteConfirm": "Supprimer {{count}} conversation(s)", + "noConversations": "Aucune conversation", + "archived": "Archivée", + "conversationsToDelete": "Conversations à supprimer", + "archiveConfirmDescription": "Êtes-vous sûr de vouloir archiver les conversations sélectionnées ?", + "selectMode": "Sélectionner", + "exitSelectMode": "Terminé" }, "ideation": { "converting": "Conversion...", diff --git a/apps/frontend/src/shared/types/insights.ts b/apps/frontend/src/shared/types/insights.ts index fefbf04cc4..731f24286e 100644 --- a/apps/frontend/src/shared/types/insights.ts +++ b/apps/frontend/src/shared/types/insights.ts @@ -198,6 +198,7 @@ export interface InsightsSession { modelConfig?: InsightsModelConfig; // Per-session model configuration createdAt: Date; updatedAt: Date; + archivedAt?: Date; } // Summary of a session for the history list (without full messages) @@ -209,6 +210,7 @@ export interface InsightsSessionSummary { modelConfig?: InsightsModelConfig; // For displaying model indicator in sidebar createdAt: Date; updatedAt: Date; + archivedAt?: Date; } export interface InsightsChatStatus { diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index b1fc2c4b63..6ec1a69638 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -783,10 +783,14 @@ export interface ElectronAPI { description: string, metadata?: TaskMetadata ) => Promise>; - listInsightsSessions: (projectId: string) => Promise>; + listInsightsSessions: (projectId: string, includeArchived?: boolean) => Promise>; newInsightsSession: (projectId: string) => Promise>; switchInsightsSession: (projectId: string, sessionId: string) => Promise>; deleteInsightsSession: (projectId: string, sessionId: string) => Promise; + deleteInsightsSessions: (projectId: string, sessionIds: string[]) => Promise>; + archiveInsightsSession: (projectId: string, sessionId: string) => Promise; + archiveInsightsSessions: (projectId: string, sessionIds: string[]) => Promise>; + unarchiveInsightsSession: (projectId: string, sessionId: string) => Promise; renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise; updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise;