mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
[codex] Archive chats and restore them from settings (#26)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Archive chats and restore them from settings * Refresh project list after restore --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<SessionStore>>) -> Vec<Session> {
|
||||
store.list_archived_sessions()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn archive_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
store.archive_session(&session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unarchive_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
store.unarchive_session(&session_id)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Session> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let mut list: Vec<Session> = sessions.values().cloned().collect();
|
||||
let mut list: Vec<Session> = 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<Session> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let mut list: Vec<Session> = 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
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct Session {
|
||||
pub message_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_message_preview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub archived_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Partial update for a session — only provided fields are applied.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<void>;
|
||||
updateSession: (id: string, patch: Partial<ChatSession>) => void;
|
||||
archiveSession: (id: string) => Promise<void>;
|
||||
unarchiveSession: (id: string) => Promise<void>;
|
||||
|
||||
// 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<ChatSessionStore>((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<ChatSessionStore>((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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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<ProjectInfo[]>([]);
|
||||
const [archivedChats, setArchivedChats] = useState<Session[]>([]);
|
||||
const [loadingArchived, setLoadingArchived] = useState(true);
|
||||
const [loadingArchivedChats, setLoadingArchivedChats] = useState(true);
|
||||
const [deletingProject, setDeletingProject] = useState<ProjectInfo | null>(
|
||||
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) {
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestore(project.id)}
|
||||
onClick={() => handleRestoreProject(project.id)}
|
||||
className="px-2 py-1 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors"
|
||||
>
|
||||
Restore
|
||||
@@ -239,6 +265,53 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "chats" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Chats</h3>
|
||||
<p className="mt-1 text-sm text-foreground-secondary">
|
||||
Restore archived chats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Archived Chats</h3>
|
||||
{!loadingArchivedChats && archivedChats.length === 0 && (
|
||||
<p className="text-xs text-foreground-secondary">
|
||||
No archived chats.
|
||||
</p>
|
||||
)}
|
||||
{archivedChats.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm">
|
||||
{session.title}
|
||||
</div>
|
||||
<p className="truncate text-xs text-foreground-secondary">
|
||||
{session.projectId
|
||||
? "Project chat"
|
||||
: "Standalone chat"}{" "}
|
||||
-{" "}
|
||||
{session.messageCount === 1
|
||||
? "1 message"
|
||||
: `${session.messageCount} messages`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestoreChat(session.id)}
|
||||
className="flex-shrink-0 rounded-md border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-background-tertiary"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "about" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">About</h3>
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate?.("projects")}
|
||||
onClick={() => {
|
||||
if (showAll) {
|
||||
setShowAll(false);
|
||||
} else {
|
||||
// If more than 8 items, navigate to full view instead
|
||||
if (projectChats.length > 8) {
|
||||
onNavigate?.("projects");
|
||||
} else {
|
||||
setShowAll(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center w-full py-1 pl-8 pr-2.5 rounded-md text-[11px]",
|
||||
"flex items-center gap-1.5 w-full pl-8 pr-3 py-1 text-[11px]",
|
||||
"text-foreground-secondary/60 hover:text-foreground-secondary transition-colors duration-150",
|
||||
)}
|
||||
>
|
||||
View all {projectChats.length} chats
|
||||
{showAll ? (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
{projectChats.length > 8
|
||||
? `View all ${projectChats.length} chats`
|
||||
: `${projectChats.length - MAX_VISIBLE_CHATS} more`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -378,7 +410,7 @@ export function SidebarProjectsSection({
|
||||
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{projectTabs.standalone.map((tab) => (
|
||||
{visibleRecents.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
@@ -397,7 +429,7 @@ export function SidebarProjectsSection({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{projectTabs.standalone.map((tab) => {
|
||||
{visibleRecents.map((tab) => {
|
||||
const isActive = activeTabId === tab.id;
|
||||
const isOpen = tab.isOpenTab ?? false;
|
||||
return (
|
||||
@@ -426,6 +458,30 @@ export function SidebarProjectsSection({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasMoreRecents && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllRecents((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 w-full px-2.5 py-1 text-[11px]",
|
||||
"text-foreground-secondary/60 hover:text-foreground-secondary transition-colors duration-150",
|
||||
)}
|
||||
>
|
||||
{showAllRecents ? (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
{projectTabs.standalone.length > 8
|
||||
? `View all ${projectTabs.standalone.length} chats`
|
||||
: `${projectTabs.standalone.length - MAX_VISIBLE_CHATS} more`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,10 @@ export async function listSessions(): Promise<Session[]> {
|
||||
return invoke("list_sessions");
|
||||
}
|
||||
|
||||
export async function listArchivedSessions(): Promise<Session[]> {
|
||||
return invoke("list_archived_sessions");
|
||||
}
|
||||
|
||||
export async function getSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<Message[]> {
|
||||
@@ -42,6 +46,14 @@ export async function deleteSession(sessionId: string): Promise<void> {
|
||||
return invoke("delete_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<void> {
|
||||
return invoke("archive_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function unarchiveSession(sessionId: string): Promise<void> {
|
||||
return invoke("unarchive_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function saveUiState(
|
||||
openTabIds: string[],
|
||||
activeTabId: string | null,
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface Session {
|
||||
modelName?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user