[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:
Wes
2026-03-31 12:43:49 -07:00
committed by GitHub
parent f29cebb505
commit 562deafe4d
10 changed files with 275 additions and 15 deletions
@@ -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)
}
+3
View File
@@ -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,
+41 -1
View File
@@ -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
+2
View File
@@ -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.
+28 -1
View File
@@ -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>
)}
</>
+12
View File
@@ -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,
+1
View File
@@ -40,6 +40,7 @@ export interface Session {
modelName?: string;
createdAt: string;
updatedAt: string;
archivedAt?: string;
messageCount: number;
}