From 562deafe4dcccf5790ea233df7a0ae4e21c82c52 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 31 Mar 2026 12:43:49 -0700 Subject: [PATCH] [codex] Archive chats and restore them from settings (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add archive/unarchive session support to frontend types, API, and store Replace the delete-based archiveSession with actual archive/unarchive API calls. Add archivedAt field to Session and ChatSession types, wire up new Tauri invoke bindings, and update the Zustand store to mark sessions as archived instead of removing them. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add show more/less toggle to sidebar project and recents sections Show 3 chats by default in both project sections and recents. For ≤8 items, toggle inline expansion. For >8 items, navigate to the full projects view instead. Follows the goose-spence pattern. Co-Authored-By: Claude Opus 4.6 (1M context) * Archive chats and restore them from settings * Refresh project list after restore --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/src-tauri/src/commands/sessions.rs | 21 +++++ ui/goose2/src-tauri/src/lib.rs | 3 + ui/goose2/src-tauri/src/services/sessions.rs | 42 +++++++++- ui/goose2/src-tauri/src/types/agents.rs | 2 + ui/goose2/src/app/AppShell.tsx | 29 ++++++- .../features/chat/stores/chatSessionStore.ts | 35 +++++++-- .../features/settings/ui/SettingsModal.tsx | 77 ++++++++++++++++++- .../sidebar/ui/SidebarProjectsSection.tsx | 68 ++++++++++++++-- ui/goose2/src/shared/api/chat.ts | 12 +++ ui/goose2/src/shared/types/chat.ts | 1 + 10 files changed, 275 insertions(+), 15 deletions(-) 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; }