diff --git a/ui/goose2/src-tauri/src/commands/sessions.rs b/ui/goose2/src-tauri/src/commands/sessions.rs index 3e14a82243..4a1f2995bc 100644 --- a/ui/goose2/src-tauri/src/commands/sessions.rs +++ b/ui/goose2/src-tauri/src/commands/sessions.rs @@ -43,3 +43,24 @@ pub fn delete_session( ) -> Result<(), String> { store.delete_session(&session_id) } + +#[tauri::command] +pub fn list_archived_sessions(store: State<'_, Arc>) -> Vec { + store.list_archived_sessions() +} + +#[tauri::command] +pub fn archive_session( + store: State<'_, Arc>, + session_id: String, +) -> Result<(), String> { + store.archive_session(&session_id) +} + +#[tauri::command] +pub fn unarchive_session( + store: State<'_, Arc>, + session_id: String, +) -> Result<(), String> { + store.unarchive_session(&session_id) +} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index c28593d344..712f0873e9 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -48,6 +48,9 @@ pub fn run() { commands::sessions::get_session_messages, commands::sessions::update_session, commands::sessions::delete_session, + commands::sessions::list_archived_sessions, + commands::sessions::archive_session, + commands::sessions::unarchive_session, commands::ui_state::save_ui_state, commands::ui_state::load_ui_state, commands::chat::chat_send_message, diff --git a/ui/goose2/src-tauri/src/services/sessions.rs b/ui/goose2/src-tauri/src/services/sessions.rs index 54b3493c91..cdcb57f24c 100644 --- a/ui/goose2/src-tauri/src/services/sessions.rs +++ b/ui/goose2/src-tauri/src/services/sessions.rs @@ -100,6 +100,7 @@ impl SessionStore { updated_at: now, message_count: 0, last_message_preview: None, + archived_at: None, }; // Write empty messages file @@ -139,6 +140,7 @@ impl SessionStore { updated_at: now, message_count: 0, last_message_preview: None, + archived_at: None, }; self.save_messages(id, &[]); @@ -151,12 +153,50 @@ impl SessionStore { pub fn list_sessions(&self) -> Vec { let sessions = self.sessions.lock().unwrap(); - let mut list: Vec = sessions.values().cloned().collect(); + let mut list: Vec = sessions + .values() + .filter(|s| s.archived_at.is_none()) + .cloned() + .collect(); // Sort by updated_at descending (most recent first) list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); list } + pub fn list_archived_sessions(&self) -> Vec { + let sessions = self.sessions.lock().unwrap(); + let mut list: Vec = sessions + .values() + .filter(|s| s.archived_at.is_some()) + .cloned() + .collect(); + // Sort by archived_at descending (most recently archived first) + list.sort_by(|a, b| b.archived_at.cmp(&a.archived_at)); + list + } + + pub fn archive_session(&self, id: &str) -> Result<(), String> { + let mut sessions = self.sessions.lock().unwrap(); + let session = sessions + .get_mut(id) + .ok_or_else(|| format!("Session '{}' not found", id))?; + + session.archived_at = Some(chrono::Utc::now().to_rfc3339()); + self.save_metadata(&sessions); + Ok(()) + } + + pub fn unarchive_session(&self, id: &str) -> Result<(), String> { + let mut sessions = self.sessions.lock().unwrap(); + let session = sessions + .get_mut(id) + .ok_or_else(|| format!("Session '{}' not found", id))?; + + session.archived_at = None; + self.save_metadata(&sessions); + Ok(()) + } + pub fn add_message(&self, session_id: &str, message: Message) -> Result<(), String> { let mut sessions = self.sessions.lock().unwrap(); let session = sessions diff --git a/ui/goose2/src-tauri/src/types/agents.rs b/ui/goose2/src-tauri/src/types/agents.rs index 6e5030aad5..dad44e8d0f 100644 --- a/ui/goose2/src-tauri/src/types/agents.rs +++ b/ui/goose2/src-tauri/src/types/agents.rs @@ -88,6 +88,8 @@ pub struct Session { pub message_count: u32, #[serde(skip_serializing_if = "Option::is_none")] pub last_message_preview: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub archived_at: Option, } /// Partial update for a session — only provided fields are applied. diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index f81de46ff9..fbc21572f2 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -229,7 +229,34 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [chatStore, sessionStore], ); - const handleArchiveChat = closeAndCleanupTab; + const handleArchiveChat = useCallback( + async (tabId: string) => { + const { activeTabId: currentActiveTabId } = + useChatSessionStore.getState(); + const wasActiveTab = currentActiveTabId === tabId; + + try { + await sessionStore.archiveSession(tabId); + chatStore.cleanupSession(tabId); + + if (!wasActiveTab) { + return; + } + + const { activeTabId: newActiveTabId } = useChatSessionStore.getState(); + if (newActiveTabId) { + chatStore.setActiveSession(newActiveTabId); + setActiveView("chat"); + loadSessionMessages(newActiveTabId); + } else { + setActiveView("home"); + } + } catch { + // best-effort + } + }, + [chatStore, loadSessionMessages, sessionStore], + ); const handleEditProject = useCallback( (projectId: string) => { diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index 354e3754ce..1c2db156e1 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -2,7 +2,8 @@ import { create } from "zustand"; import { createSession as apiCreateSession, listSessions as apiListSessions, - deleteSession as apiDeleteSession, + archiveSession as apiArchiveSession, + unarchiveSession as apiUnarchiveSession, updateSession as apiUpdateSession, saveUiState as apiSaveUiState, loadUiState as apiLoadUiState, @@ -20,6 +21,7 @@ export interface ChatSession { modelName?: string; createdAt: string; // ISO timestamp updatedAt: string; + archivedAt?: string; } interface ChatSessionStoreState { @@ -41,6 +43,7 @@ interface ChatSessionStoreActions { loadSessions: () => Promise; updateSession: (id: string, patch: Partial) => void; archiveSession: (id: string) => Promise; + unarchiveSession: (id: string) => Promise; // Tab management openTab: (sessionId: string) => void; @@ -70,6 +73,7 @@ function sessionToChatSession(session: Session): ChatSession { modelName: session.modelName, createdAt: session.createdAt, updatedAt: session.updatedAt, + archivedAt: session.archivedAt, }; } @@ -146,6 +150,8 @@ export const useChatSessionStore = create((set, get) => ({ }, archiveSession: async (id) => { + const { openTabIds, activeTabId } = get(); + // Remove from open tabs immediately for responsive UI set((state) => { const newOpenTabIds = state.openTabIds.filter((tabId) => tabId !== id); @@ -160,14 +166,33 @@ export const useChatSessionStore = create((set, get) => ({ }); get().persistTabState(); - // Delete from backend — only remove from local sessions on success + // Archive on backend — update local state on success try { - await apiDeleteSession(id); + await apiArchiveSession(id); + const archivedAt = new Date().toISOString(); set((state) => ({ - sessions: state.sessions.filter((s) => s.id !== id), + sessions: state.sessions + .map((s) => (s.id === id ? { ...s, archivedAt } : s)) + .filter((s) => !s.archivedAt), })); } catch (err) { - console.error("Failed to delete session from backend:", err); + set({ + openTabIds, + activeTabId, + }); + get().persistTabState(); + console.error("Failed to archive session:", err); + throw err; + } + }, + + unarchiveSession: async (id) => { + try { + await apiUnarchiveSession(id); + await get().loadSessions(); + } catch (err) { + console.error("Failed to unarchive session:", err); + throw err; } }, diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index e4087b3e00..1deab59209 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -5,6 +5,7 @@ import { Settings2, FolderKanban, Info, + MessageSquare, Stethoscope, X, } from "lucide-react"; @@ -16,11 +17,16 @@ import { deleteProject, type ProjectInfo, } from "@/features/projects/api/projects"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { listArchivedSessions } from "@/shared/api/chat"; +import type { Session } from "@/shared/types/chat"; const NAV_ITEMS = [ { id: "appearance", label: "Appearance", icon: Palette }, { id: "general", label: "General", icon: Settings2 }, { id: "projects", label: "Projects", icon: FolderKanban }, + { id: "chats", label: "Chats", icon: MessageSquare }, { id: "doctor", label: "Doctor", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ] as const; @@ -36,7 +42,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) { const [isLoaded, setIsLoaded] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); const [archivedProjects, setArchivedProjects] = useState([]); + const [archivedChats, setArchivedChats] = useState([]); const [loadingArchived, setLoadingArchived] = useState(true); + const [loadingArchivedChats, setLoadingArchivedChats] = useState(true); const [deletingProject, setDeletingProject] = useState( null, ); @@ -55,15 +63,33 @@ export function SettingsModal({ onClose }: SettingsModalProps) { .finally(() => setLoadingArchived(false)); }, []); - const handleRestore = async (id: string) => { + // Load archived chats on mount + useEffect(() => { + listArchivedSessions() + .then(setArchivedChats) + .catch(() => setArchivedChats([])) + .finally(() => setLoadingArchivedChats(false)); + }, []); + + const handleRestoreProject = async (id: string) => { try { await restoreProject(id); + await useProjectStore.getState().fetchProjects(); setArchivedProjects((prev) => prev.filter((p) => p.id !== id)); } catch { // best-effort } }; + const handleRestoreChat = async (id: string) => { + try { + await useChatSessionStore.getState().unarchiveSession(id); + setArchivedChats((prev) => prev.filter((session) => session.id !== id)); + } catch { + // best-effort + } + }; + const handleDelete = async (id: string) => { try { await deleteProject(id); @@ -221,7 +247,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
)} + {activeSection === "chats" && ( +
+
+

Chats

+

+ Restore archived chats. +

+
+ +
+

Archived Chats

+ {!loadingArchivedChats && archivedChats.length === 0 && ( +

+ No archived chats. +

+ )} + {archivedChats.map((session) => ( +
+
+
+ {session.title} +
+

+ {session.projectId + ? "Project chat" + : "Standalone chat"}{" "} + -{" "} + {session.messageCount === 1 + ? "1 message" + : `${session.messageCount} messages`} +

+
+ +
+ ))} +
+
+ )} {activeSection === "about" && (

About

diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx index 7ec5a78e17..711a260878 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -148,7 +148,10 @@ function ProjectSection({ onArchiveProject?: (projectId: string) => void; onArchiveChat?: (tabId: string) => void; }) { - const visibleChats = projectChats.slice(0, MAX_VISIBLE_CHATS); + const [showAll, setShowAll] = useState(false); + const visibleChats = showAll + ? projectChats + : projectChats.slice(0, MAX_VISIBLE_CHATS); const hasMore = projectChats.length > MAX_VISIBLE_CHATS; return ( @@ -234,13 +237,36 @@ function ProjectSection({ {hasMore && ( )}
@@ -266,6 +292,12 @@ export function SidebarProjectsSection({ onArchiveProject, onArchiveChat, }: SidebarProjectsSectionProps) { + const [showAllRecents, setShowAllRecents] = useState(false); + const visibleRecents = showAllRecents + ? projectTabs.standalone + : projectTabs.standalone.slice(0, MAX_VISIBLE_CHATS); + const hasMoreRecents = projectTabs.standalone.length > MAX_VISIBLE_CHATS; + return (
- {projectTabs.standalone.map((tab) => ( + {visibleRecents.map((tab) => ( + )}
)} diff --git a/ui/goose2/src/shared/api/chat.ts b/ui/goose2/src/shared/api/chat.ts index 0cd099e93d..9fcda0bf61 100644 --- a/ui/goose2/src/shared/api/chat.ts +++ b/ui/goose2/src/shared/api/chat.ts @@ -13,6 +13,10 @@ export async function listSessions(): Promise { return invoke("list_sessions"); } +export async function listArchivedSessions(): Promise { + return invoke("list_archived_sessions"); +} + export async function getSessionMessages( sessionId: string, ): Promise { @@ -42,6 +46,14 @@ export async function deleteSession(sessionId: string): Promise { return invoke("delete_session", { sessionId }); } +export async function archiveSession(sessionId: string): Promise { + return invoke("archive_session", { sessionId }); +} + +export async function unarchiveSession(sessionId: string): Promise { + return invoke("unarchive_session", { sessionId }); +} + export async function saveUiState( openTabIds: string[], activeTabId: string | null, diff --git a/ui/goose2/src/shared/types/chat.ts b/ui/goose2/src/shared/types/chat.ts index b39f3c1e9b..f241cdf8da 100644 --- a/ui/goose2/src/shared/types/chat.ts +++ b/ui/goose2/src/shared/types/chat.ts @@ -40,6 +40,7 @@ export interface Session { modelName?: string; createdAt: string; updatedAt: string; + archivedAt?: string; messageCount: number; }