From f36445d0007b25ff0d0a36ed6c979c64cb002e20 Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Mon, 10 Aug 2026 18:04:24 -0400 Subject: [PATCH 1/5] feat: automatically archive inactive chats Co-authored-by: Goose --- src/app/AppShell.tsx | 7 + .../sessions/hooks/useAutoArchiveSessions.ts | 120 ++++++++++++++++++ .../sessions/lib/autoArchiveSessions.test.ts | 86 +++++++++++++ .../sessions/lib/autoArchiveSessions.ts | 58 +++++++++ .../__tests__/autoArchivePreference.test.ts | 36 ++++++ .../settings/lib/autoArchivePreference.ts | 116 +++++++++++++++++ src/features/settings/ui/ArchiveSettings.tsx | 2 + .../settings/ui/AutoArchiveChatsSection.tsx | 52 ++++++++ .../AutoArchiveChatsSection.test.tsx | 46 +++++++ src/shared/i18n/locales/en/settings.json | 15 ++- src/shared/i18n/locales/es/settings.json | 16 ++- 11 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 src/features/sessions/hooks/useAutoArchiveSessions.ts create mode 100644 src/features/sessions/lib/autoArchiveSessions.test.ts create mode 100644 src/features/sessions/lib/autoArchiveSessions.ts create mode 100644 src/features/settings/lib/__tests__/autoArchivePreference.test.ts create mode 100644 src/features/settings/lib/autoArchivePreference.ts create mode 100644 src/features/settings/ui/AutoArchiveChatsSection.tsx create mode 100644 src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 486f94304..11eeceaf5 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -21,6 +21,7 @@ import { requiresWorkspaceStartup, type ProjectInfo, } from "@/features/projects/api/projects"; +import { useAutoArchiveSessions } from "@/features/sessions/hooks/useAutoArchiveSessions"; import { DEFAULT_SETTINGS_SECTION, resolveEnabledSettingsSection, @@ -3677,6 +3678,12 @@ export function AppShell({ [cleanupChatSession, confirmGitCleanup, setActiveSession, t], ); + const handleAutoArchiveChat = useCallback( + (sessionId: string) => archiveChat(sessionId, "reject"), + [archiveChat], + ); + useAutoArchiveSessions(handleAutoArchiveChat); + const handleArchiveChat = useCallback( (sessionId: string) => archiveChat(sessionId, "confirm"), [archiveChat], diff --git a/src/features/sessions/hooks/useAutoArchiveSessions.ts b/src/features/sessions/hooks/useAutoArchiveSessions.ts new file mode 100644 index 000000000..29828873e --- /dev/null +++ b/src/features/sessions/hooks/useAutoArchiveSessions.ts @@ -0,0 +1,120 @@ +import { useEffect } from "react"; +import { + AUTO_ARCHIVE_CHANGED_EVENT, + getAutoArchiveAfterMs, +} from "@/features/settings/lib/autoArchivePreference"; +import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; +import { getLayout, HOME_LAYOUT_ID } from "@/features/layout/api/layout"; +import { loadAllSessionsForWorkspaceCleanup } from "@/features/chat/lib/sessionWorkspaceCleanup"; +import { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +import { getAutoArchiveSessionCandidates } from "../lib/autoArchiveSessions"; + +const AUTO_ARCHIVE_SWEEP_INTERVAL_MS = 60 * 60 * 1000; + +interface AutoArchiveResult { + ok: boolean; +} + +interface RunAutoArchiveSweepOptions { + archiveSession: (sessionId: string) => Promise; + nowMs?: number; +} + +let sweepPromise: Promise | null = null; + +export async function runAutoArchiveSweep({ + archiveSession, + nowMs = Date.now(), +}: RunAutoArchiveSweepOptions): Promise { + const afterMs = getAutoArchiveAfterMs(); + if (afterMs === null) return; + + // Pins live in the Home layout rather than on session metadata. Read the + // durable layout so pins remain protected even when Home has not been opened + // during this app launch, then include any optimistic in-memory pins too. + const [sessions, homeLayout] = await Promise.all([ + loadAllSessionsForWorkspaceCleanup(), + getLayout(HOME_LAYOUT_ID), + ]); + const persistedPinWidgets = homeLayout.items + .filter((item) => item.kind === "session") + .map((item) => ({ type: "chatPin", state: { sessionId: item.targetId } })); + const homeWidgets = [ + ...persistedPinWidgets, + ...useHomeWidgetStore.getState().instances, + ]; + const sessionStore = useChatSessionStore.getState(); + const activeSessionId = sessionStore.activeSessionId; + const localSessionsById = new Map( + sessionStore.sessions.map((session) => [session.id, session]), + ); + const candidates = getAutoArchiveSessionCandidates({ + sessions: sessions.map((session) => { + const localSession = localSessionsById.get(session.id); + return localSession + ? ({ ...session, ...localSession } satisfies ChatSession) + : session; + }), + homeWidgets, + afterMs, + nowMs, + }).filter((session) => session.id !== activeSessionId); + + // Use the same serialized archive transaction as manual actions. The + // noninteractive policy safely skips running chats and workspaces that would + // require confirmation rather than interrupting work or discarding files. + for (const session of candidates) { + // The complete ACP list can include a paged-out session that is not yet in + // the renderer store. Add its metadata so the shared archive transaction + // can inspect and mutate it exactly like a currently visible chat. + if (!useChatSessionStore.getState().getSession(session.id)) { + useChatSessionStore.getState().addSession(session); + } + await archiveSession(session.id); + } +} + +export function useAutoArchiveSessions( + archiveSession: (sessionId: string) => Promise, +): void { + useEffect(() => { + let cancelled = false; + + const sweep = () => { + if (cancelled || document.visibilityState === "hidden") return; + if (sweepPromise) return; + + sweepPromise = runAutoArchiveSweep({ archiveSession }) + .catch((error) => { + console.error( + "Failed to automatically archive inactive chats:", + error, + ); + }) + .finally(() => { + sweepPromise = null; + }); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") sweep(); + }; + + sweep(); + const intervalId = window.setInterval( + sweep, + AUTO_ARCHIVE_SWEEP_INTERVAL_MS, + ); + window.addEventListener(AUTO_ARCHIVE_CHANGED_EVENT, sweep); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + window.removeEventListener(AUTO_ARCHIVE_CHANGED_EVENT, sweep); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [archiveSession]); +} diff --git a/src/features/sessions/lib/autoArchiveSessions.test.ts b/src/features/sessions/lib/autoArchiveSessions.test.ts new file mode 100644 index 000000000..ef990a34b --- /dev/null +++ b/src/features/sessions/lib/autoArchiveSessions.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import { + getAutoArchiveSessionCandidates, + getPinnedChatSessionIds, +} from "./autoArchiveSessions"; + +const NOW = Date.parse("2026-08-10T12:00:00.000Z"); +const DAY_MS = 24 * 60 * 60 * 1000; + +function session( + id: string, + activityAt: string, + overrides: Partial = {}, +): ChatSession { + return { + id, + title: id, + createdAt: activityAt, + updatedAt: activityAt, + lastMessageAt: activityAt, + messageCount: 1, + ...overrides, + }; +} + +describe("getPinnedChatSessionIds", () => { + it("returns only valid chat pin session ids", () => { + expect( + getPinnedChatSessionIds([ + { type: "chatPin", state: { sessionId: "pinned" } }, + { type: "chatPin", state: {} }, + { type: "projectArtifactPin", state: { sessionId: "not-a-chat" } }, + ]), + ).toEqual(new Set(["pinned"])); + }); +}); + +describe("getAutoArchiveSessionCandidates", () => { + it("selects stale unpinned chats and uses last message activity", () => { + const sessions = [ + session("stale", "2026-08-02T12:00:00.000Z"), + session("recent-message", "2026-08-09T12:00:00.000Z", { + updatedAt: "2026-08-01T12:00:00.000Z", + }), + session("pinned", "2026-08-01T12:00:00.000Z"), + session("archived", "2026-08-01T12:00:00.000Z", { + archivedAt: "2026-08-05T12:00:00.000Z", + }), + session("draft", "2026-08-01T12:00:00.000Z", { + creationState: "pending", + }), + ]; + + expect( + getAutoArchiveSessionCandidates({ + sessions, + homeWidgets: [{ type: "chatPin", state: { sessionId: "pinned" } }], + afterMs: 7 * DAY_MS, + nowMs: NOW, + }).map((candidate) => candidate.id), + ).toEqual(["stale"]); + }); + + it("is disabled when no duration is configured", () => { + expect( + getAutoArchiveSessionCandidates({ + sessions: [session("stale", "2026-01-01T00:00:00.000Z")], + homeWidgets: [], + afterMs: null, + nowMs: NOW, + }), + ).toEqual([]); + }); + + it("ignores malformed activity timestamps", () => { + expect( + getAutoArchiveSessionCandidates({ + sessions: [session("bad-date", "not-a-date")], + homeWidgets: [], + afterMs: DAY_MS, + nowMs: NOW, + }), + ).toEqual([]); + }); +}); diff --git a/src/features/sessions/lib/autoArchiveSessions.ts b/src/features/sessions/lib/autoArchiveSessions.ts new file mode 100644 index 000000000..d3c4d63bc --- /dev/null +++ b/src/features/sessions/lib/autoArchiveSessions.ts @@ -0,0 +1,58 @@ +import { sessionActivityAt } from "@/features/chat/lib/sessionActivity"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +interface HomeWidgetPin { + type: string; + state?: Record; +} + +export interface AutoArchiveSessionCandidateOptions { + sessions: ChatSession[]; + homeWidgets: HomeWidgetPin[]; + afterMs: number | null; + nowMs?: number; +} + +export function getPinnedChatSessionIds( + homeWidgets: HomeWidgetPin[], +): Set { + const sessionIds = new Set(); + for (const widget of homeWidgets) { + const sessionId = widget.state?.sessionId; + if (widget.type === "chatPin" && typeof sessionId === "string") { + sessionIds.add(sessionId); + } + } + return sessionIds; +} + +/** + * Select inactive sessions that are safe to consider for automatic archiving. + * The caller remains responsible for checking live runtime state and applying + * the normal archive transaction. + */ +export function getAutoArchiveSessionCandidates({ + sessions, + homeWidgets, + afterMs, + nowMs = Date.now(), +}: AutoArchiveSessionCandidateOptions): ChatSession[] { + if (afterMs === null) return []; + + const pinnedSessionIds = getPinnedChatSessionIds(homeWidgets); + const cutoffMs = nowMs - afterMs; + + return sessions.filter((session) => { + if ( + session.archivedAt || + session.creationState || + session.pinnedLoadState || + pinnedSessionIds.has(session.id) + ) { + return false; + } + + const activityMs = Date.parse(sessionActivityAt(session)); + return Number.isFinite(activityMs) && activityMs <= cutoffMs; + }); +} diff --git a/src/features/settings/lib/__tests__/autoArchivePreference.test.ts b/src/features/settings/lib/__tests__/autoArchivePreference.test.ts new file mode 100644 index 000000000..929c22449 --- /dev/null +++ b/src/features/settings/lib/__tests__/autoArchivePreference.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AUTO_ARCHIVE_CHANGED_EVENT, + AUTO_ARCHIVE_STORAGE_KEY, + getAutoArchiveAfter, + getAutoArchiveAfterMs, + setAutoArchiveAfter, +} from "../autoArchivePreference"; + +describe("auto archive preference", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("defaults to never and rejects invalid persisted values", () => { + expect(getAutoArchiveAfter()).toBe("never"); + localStorage.setItem(AUTO_ARCHIVE_STORAGE_KEY, "tomorrow-ish"); + expect(getAutoArchiveAfter()).toBe("never"); + }); + + it("persists changes and notifies mounted consumers", () => { + const listener = vi.fn(); + window.addEventListener(AUTO_ARCHIVE_CHANGED_EVENT, listener); + + setAutoArchiveAfter("30-days"); + + expect(localStorage.getItem(AUTO_ARCHIVE_STORAGE_KEY)).toBe("30-days"); + expect(listener).toHaveBeenCalledOnce(); + window.removeEventListener(AUTO_ARCHIVE_CHANGED_EVENT, listener); + }); + + it("converts configured durations to milliseconds", () => { + expect(getAutoArchiveAfterMs("never")).toBeNull(); + expect(getAutoArchiveAfterMs("7-days")).toBe(7 * 24 * 60 * 60 * 1000); + }); +}); diff --git a/src/features/settings/lib/autoArchivePreference.ts b/src/features/settings/lib/autoArchivePreference.ts new file mode 100644 index 000000000..691d9c073 --- /dev/null +++ b/src/features/settings/lib/autoArchivePreference.ts @@ -0,0 +1,116 @@ +import { useCallback, useSyncExternalStore } from "react"; + +export const AUTO_ARCHIVE_STORAGE_KEY = "goose:auto-archive-unpinned-after"; +export const AUTO_ARCHIVE_CHANGED_EVENT = + "goose:auto-archive-unpinned-after-changed"; + +export type AutoArchiveAfter = + | "never" + | "1-day" + | "7-days" + | "14-days" + | "30-days" + | "90-days"; + +export const AUTO_ARCHIVE_OPTIONS: ReadonlyArray<{ + value: AutoArchiveAfter; + days: number | null; +}> = [ + { value: "never", days: null }, + { value: "1-day", days: 1 }, + { value: "7-days", days: 7 }, + { value: "14-days", days: 14 }, + { value: "30-days", days: 30 }, + { value: "90-days", days: 90 }, +]; + +const AUTO_ARCHIVE_VALUES = new Set( + AUTO_ARCHIVE_OPTIONS.map((option) => option.value), +); +const DEFAULT_AUTO_ARCHIVE_AFTER: AutoArchiveAfter = "never"; +const DAY_MS = 24 * 60 * 60 * 1000; + +function isAutoArchiveAfter(value: string | null): value is AutoArchiveAfter { + return value !== null && AUTO_ARCHIVE_VALUES.has(value as AutoArchiveAfter); +} + +export function getAutoArchiveAfter(): AutoArchiveAfter { + if (typeof window === "undefined") return DEFAULT_AUTO_ARCHIVE_AFTER; + + try { + const stored = window.localStorage.getItem(AUTO_ARCHIVE_STORAGE_KEY); + return isAutoArchiveAfter(stored) ? stored : DEFAULT_AUTO_ARCHIVE_AFTER; + } catch { + return DEFAULT_AUTO_ARCHIVE_AFTER; + } +} + +export function getAutoArchiveAfterMs( + value: AutoArchiveAfter = getAutoArchiveAfter(), +): number | null { + const option = AUTO_ARCHIVE_OPTIONS.find( + (candidate) => candidate.value === value, + ); + return option?.days == null ? null : option.days * DAY_MS; +} + +export function setAutoArchiveAfter(value: AutoArchiveAfter): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(AUTO_ARCHIVE_STORAGE_KEY, value); + } catch { + // localStorage can be unavailable in restricted contexts. + } + window.dispatchEvent( + new CustomEvent(AUTO_ARCHIVE_CHANGED_EVENT, { detail: { value } }), + ); +} + +const listeners = new Set<() => void>(); +let removeWindowListeners: (() => void) | undefined; + +function notifyListeners() { + for (const listener of listeners) listener(); +} + +function handleStorageChange(event: StorageEvent) { + if (event.key === AUTO_ARCHIVE_STORAGE_KEY || event.key === null) { + notifyListeners(); + } +} + +function subscribe(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + + listeners.add(onStoreChange); + if (!removeWindowListeners) { + window.addEventListener(AUTO_ARCHIVE_CHANGED_EVENT, notifyListeners); + window.addEventListener("storage", handleStorageChange); + removeWindowListeners = () => { + window.removeEventListener(AUTO_ARCHIVE_CHANGED_EVENT, notifyListeners); + window.removeEventListener("storage", handleStorageChange); + }; + } + + return () => { + listeners.delete(onStoreChange); + if (listeners.size === 0) { + removeWindowListeners?.(); + removeWindowListeners = undefined; + } + }; +} + +export function useAutoArchivePreference() { + const value = useSyncExternalStore( + subscribe, + getAutoArchiveAfter, + () => DEFAULT_AUTO_ARCHIVE_AFTER, + ); + const setValue = useCallback((nextValue: AutoArchiveAfter) => { + setAutoArchiveAfter(nextValue); + }, []); + + return { value, setValue, afterMs: getAutoArchiveAfterMs(value) }; +} diff --git a/src/features/settings/ui/ArchiveSettings.tsx b/src/features/settings/ui/ArchiveSettings.tsx index f05cde900..da3af2590 100644 --- a/src/features/settings/ui/ArchiveSettings.tsx +++ b/src/features/settings/ui/ArchiveSettings.tsx @@ -3,6 +3,7 @@ import { SettingsPage } from "@/shared/ui/SettingsPage"; import { SettingsSections } from "@/shared/ui/settings-section"; import { ArchivedChatsSection } from "./ArchivedChatsSection"; import { ArchivedProjectsSection } from "./ArchivedProjectsSection"; +import { AutoArchiveChatsSection } from "./AutoArchiveChatsSection"; export function ArchiveSettings() { const { t } = useTranslation("settings"); @@ -10,6 +11,7 @@ export function ArchiveSettings() { return ( + diff --git a/src/features/settings/ui/AutoArchiveChatsSection.tsx b/src/features/settings/ui/AutoArchiveChatsSection.tsx new file mode 100644 index 000000000..1feaf0a5f --- /dev/null +++ b/src/features/settings/ui/AutoArchiveChatsSection.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { + AUTO_ARCHIVE_OPTIONS, + type AutoArchiveAfter, + useAutoArchivePreference, +} from "@/features/settings/lib/autoArchivePreference"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select"; +import { SettingsRow } from "@/shared/ui/settings-row"; +import { SettingsSection } from "@/shared/ui/settings-section"; + +export function AutoArchiveChatsSection() { + const { t } = useTranslation("settings"); + const { value, setValue } = useAutoArchivePreference(); + + return ( + + ( + + )} + /> + + ); +} diff --git a/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx b/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx new file mode 100644 index 000000000..55a54f29c --- /dev/null +++ b/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AUTO_ARCHIVE_STORAGE_KEY, + getAutoArchiveAfter, +} from "@/features/settings/lib/autoArchivePreference"; +import { AutoArchiveChatsSection } from "../AutoArchiveChatsSection"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("AutoArchiveChatsSection", () => { + beforeEach(() => { + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("defaults to never and persists a selected inactivity duration", async () => { + const user = userEvent.setup(); + render(); + + expect( + screen.getByRole("combobox", { name: "archive.autoArchive.label" }), + ).toHaveTextContent("archive.autoArchive.options.never"); + + await user.click( + screen.getByRole("combobox", { name: "archive.autoArchive.label" }), + ); + await user.click( + screen.getByRole("option", { + name: "archive.autoArchive.options.30-days", + }), + ); + + expect(getAutoArchiveAfter()).toBe("30-days"); + expect(localStorage.getItem(AUTO_ARCHIVE_STORAGE_KEY)).toBe("30-days"); + }); +}); diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 6570c8e75..a79089a1b 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -65,7 +65,20 @@ } }, "archive": { - "title": "Archive" + "title": "Archive", + "autoArchive": { + "sectionTitle": "Automatic archiving", + "label": "Archive inactive chats", + "description": "Automatically archive chats after this much time without a message. Chats pinned to Home are never archived automatically.", + "options": { + "never": "Never", + "1-day": "After 1 day", + "7-days": "After 7 days", + "14-days": "After 14 days", + "30-days": "After 30 days", + "90-days": "After 90 days" + } + } }, "chats": { "empty": "Archived chats will show here.", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 009a7410e..5f377ba31 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -65,7 +65,20 @@ } }, "archive": { - "title": "Archivo" + "title": "Archivo", + "autoArchive": { + "sectionTitle": "Archivado automático", + "label": "Archivar chats inactivos", + "description": "Archiva automáticamente los chats después de este tiempo sin mensajes. Los chats fijados en Inicio nunca se archivan automáticamente.", + "options": { + "never": "Nunca", + "1-day": "Después de 1 día", + "7-days": "Después de 7 días", + "14-days": "Después de 14 días", + "30-days": "Después de 30 días", + "90-days": "Después de 90 días" + } + } }, "chats": { "empty": "No hay chats archivados.", @@ -339,7 +352,6 @@ "queue": "Cola", "steer": "Guiar (solo arnés de Goose)" }, - "groupChatsByProject": { "description": "Muestra grupos de proyectos en la barra lateral. Desactiva esto para tener una sola lista de chats ordenada por actividad.", "label": "Agrupar chats por proyecto" From 40f5d6a85c78afa13f128b0357db57c25deac2ca Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Mon, 10 Aug 2026 19:01:49 -0400 Subject: [PATCH 2/5] fix: require consent before auto archiving Co-authored-by: Goose --- .../__tests__/autoArchivePreference.test.ts | 9 +- .../settings/lib/autoArchivePreference.ts | 27 +++++- .../settings/ui/AutoArchiveChatsSection.tsx | 89 +++++++++++++------ .../AutoArchiveChatsSection.test.tsx | 39 +++++++- src/shared/i18n/locales/en/settings.json | 3 + src/shared/i18n/locales/es/settings.json | 3 + 6 files changed, 137 insertions(+), 33 deletions(-) diff --git a/src/features/settings/lib/__tests__/autoArchivePreference.test.ts b/src/features/settings/lib/__tests__/autoArchivePreference.test.ts index 929c22449..94997e645 100644 --- a/src/features/settings/lib/__tests__/autoArchivePreference.test.ts +++ b/src/features/settings/lib/__tests__/autoArchivePreference.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AUTO_ARCHIVE_CHANGED_EVENT, + AUTO_ARCHIVE_CONSENT_STORAGE_KEY, AUTO_ARCHIVE_STORAGE_KEY, getAutoArchiveAfter, getAutoArchiveAfterMs, @@ -12,8 +13,13 @@ describe("auto archive preference", () => { localStorage.clear(); }); - it("defaults to never and rejects invalid persisted values", () => { + it("defaults to never and rejects unconfirmed or invalid persisted values", () => { expect(getAutoArchiveAfter()).toBe("never"); + + localStorage.setItem(AUTO_ARCHIVE_STORAGE_KEY, "14-days"); + expect(getAutoArchiveAfter()).toBe("never"); + + localStorage.setItem(AUTO_ARCHIVE_CONSENT_STORAGE_KEY, "true"); localStorage.setItem(AUTO_ARCHIVE_STORAGE_KEY, "tomorrow-ish"); expect(getAutoArchiveAfter()).toBe("never"); }); @@ -25,6 +31,7 @@ describe("auto archive preference", () => { setAutoArchiveAfter("30-days"); expect(localStorage.getItem(AUTO_ARCHIVE_STORAGE_KEY)).toBe("30-days"); + expect(localStorage.getItem(AUTO_ARCHIVE_CONSENT_STORAGE_KEY)).toBe("true"); expect(listener).toHaveBeenCalledOnce(); window.removeEventListener(AUTO_ARCHIVE_CHANGED_EVENT, listener); }); diff --git a/src/features/settings/lib/autoArchivePreference.ts b/src/features/settings/lib/autoArchivePreference.ts index 691d9c073..c0291c311 100644 --- a/src/features/settings/lib/autoArchivePreference.ts +++ b/src/features/settings/lib/autoArchivePreference.ts @@ -3,6 +3,8 @@ import { useCallback, useSyncExternalStore } from "react"; export const AUTO_ARCHIVE_STORAGE_KEY = "goose:auto-archive-unpinned-after"; export const AUTO_ARCHIVE_CHANGED_EVENT = "goose:auto-archive-unpinned-after-changed"; +export const AUTO_ARCHIVE_CONSENT_STORAGE_KEY = + "goose:auto-archive-unpinned-consented"; export type AutoArchiveAfter = | "never" @@ -38,6 +40,14 @@ export function getAutoArchiveAfter(): AutoArchiveAfter { if (typeof window === "undefined") return DEFAULT_AUTO_ARCHIVE_AFTER; try { + // A duration alone is not consent. This separate marker prevents stale or + // experimental local values from ever enabling destructive behavior on a + // user's first run of the shipped feature. + if ( + window.localStorage.getItem(AUTO_ARCHIVE_CONSENT_STORAGE_KEY) !== "true" + ) { + return DEFAULT_AUTO_ARCHIVE_AFTER; + } const stored = window.localStorage.getItem(AUTO_ARCHIVE_STORAGE_KEY); return isAutoArchiveAfter(stored) ? stored : DEFAULT_AUTO_ARCHIVE_AFTER; } catch { @@ -54,11 +64,18 @@ export function getAutoArchiveAfterMs( return option?.days == null ? null : option.days * DAY_MS; } -export function setAutoArchiveAfter(value: AutoArchiveAfter): void { +function persistAutoArchivePreference( + value: AutoArchiveAfter, + consented: boolean, +): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(AUTO_ARCHIVE_STORAGE_KEY, value); + window.localStorage.setItem( + AUTO_ARCHIVE_CONSENT_STORAGE_KEY, + String(consented), + ); } catch { // localStorage can be unavailable in restricted contexts. } @@ -67,6 +84,14 @@ export function setAutoArchiveAfter(value: AutoArchiveAfter): void { ); } +export function setAutoArchiveAfter(value: AutoArchiveAfter): void { + persistAutoArchivePreference(value, true); +} + +export function resetAutoArchiveAfter(): void { + persistAutoArchivePreference(DEFAULT_AUTO_ARCHIVE_AFTER, false); +} + const listeners = new Set<() => void>(); let removeWindowListeners: (() => void) | undefined; diff --git a/src/features/settings/ui/AutoArchiveChatsSection.tsx b/src/features/settings/ui/AutoArchiveChatsSection.tsx index 1feaf0a5f..08ef0a2e9 100644 --- a/src/features/settings/ui/AutoArchiveChatsSection.tsx +++ b/src/features/settings/ui/AutoArchiveChatsSection.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { AUTO_ARCHIVE_OPTIONS, @@ -11,42 +12,74 @@ import { SelectTrigger, SelectValue, } from "@/shared/ui/select"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { SettingsRow } from "@/shared/ui/settings-row"; import { SettingsSection } from "@/shared/ui/settings-section"; export function AutoArchiveChatsSection() { - const { t } = useTranslation("settings"); + const { t } = useTranslation(["settings", "common"]); const { value, setValue } = useAutoArchivePreference(); + const [pendingValue, setPendingValue] = useState( + null, + ); + + function selectValue(nextValue: AutoArchiveAfter) { + if (nextValue === "never") { + setValue(nextValue); + return; + } + setPendingValue(nextValue); + } return ( - - ( - + selectValue(nextValue as AutoArchiveAfter) + } > - - - - {AUTO_ARCHIVE_OPTIONS.map((option) => ( - - {t(`archive.autoArchive.options.${option.value}`)} - - ))} - - - )} + + + + + {AUTO_ARCHIVE_OPTIONS.map((option) => ( + + {t(`archive.autoArchive.options.${option.value}`)} + + ))} + + + )} + /> + + + !open && setPendingValue(null)} + title={t("archive.autoArchive.confirmTitle")} + description={t("archive.autoArchive.confirmDescription", { + duration: pendingValue + ? t(`archive.autoArchive.options.${pendingValue}`).toLowerCase() + : "", + })} + cancelLabel={t("common:actions.cancel")} + confirmLabel={t("archive.autoArchive.confirmAction")} + destructive={false} + onConfirm={() => { + if (pendingValue) setValue(pendingValue); + setPendingValue(null); + }} /> - + ); } diff --git a/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx b/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx index 55a54f29c..988a75c1c 100644 --- a/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx +++ b/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + AUTO_ARCHIVE_CONSENT_STORAGE_KEY, AUTO_ARCHIVE_STORAGE_KEY, getAutoArchiveAfter, } from "@/features/settings/lib/autoArchivePreference"; @@ -23,13 +24,39 @@ describe("AutoArchiveChatsSection", () => { localStorage.clear(); }); - it("defaults to never and persists a selected inactivity duration", async () => { + it("defaults to never and does not enable a duration without confirmation", async () => { + localStorage.setItem(AUTO_ARCHIVE_STORAGE_KEY, "14-days"); const user = userEvent.setup(); render(); + const picker = screen.getByRole("combobox", { + name: "archive.autoArchive.label", + }); + expect(picker).toHaveTextContent("archive.autoArchive.options.never"); + + await user.click(picker); + await user.click( + screen.getByRole("option", { + name: "archive.autoArchive.options.30-days", + }), + ); + + expect(getAutoArchiveAfter()).toBe("never"); expect( - screen.getByRole("combobox", { name: "archive.autoArchive.label" }), - ).toHaveTextContent("archive.autoArchive.options.never"); + screen.getByRole("dialog", { + name: "archive.autoArchive.confirmTitle", + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "common:actions.cancel" }), + ); + expect(getAutoArchiveAfter()).toBe("never"); + }); + + it("persists the duration only after explicit confirmation", async () => { + const user = userEvent.setup(); + render(); await user.click( screen.getByRole("combobox", { name: "archive.autoArchive.label" }), @@ -39,8 +66,14 @@ describe("AutoArchiveChatsSection", () => { name: "archive.autoArchive.options.30-days", }), ); + await user.click( + screen.getByRole("button", { + name: "archive.autoArchive.confirmAction", + }), + ); expect(getAutoArchiveAfter()).toBe("30-days"); expect(localStorage.getItem(AUTO_ARCHIVE_STORAGE_KEY)).toBe("30-days"); + expect(localStorage.getItem(AUTO_ARCHIVE_CONSENT_STORAGE_KEY)).toBe("true"); }); }); diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index a79089a1b..70398b078 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -70,6 +70,9 @@ "sectionTitle": "Automatic archiving", "label": "Archive inactive chats", "description": "Automatically archive chats after this much time without a message. Chats pinned to Home are never archived automatically.", + "confirmTitle": "Turn on automatic archiving?", + "confirmDescription": "Berd will archive unpinned chats {{duration}} without another prompt. You can restore them from Archive or turn this off at any time.", + "confirmAction": "Turn on", "options": { "never": "Never", "1-day": "After 1 day", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 5f377ba31..7afa23ca9 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -70,6 +70,9 @@ "sectionTitle": "Archivado automático", "label": "Archivar chats inactivos", "description": "Archiva automáticamente los chats después de este tiempo sin mensajes. Los chats fijados en Inicio nunca se archivan automáticamente.", + "confirmTitle": "¿Activar el archivado automático?", + "confirmDescription": "Berd archivará los chats no fijados {{duration}} sin volver a preguntar. Puedes restaurarlos desde Archivo o desactivar esta opción cuando quieras.", + "confirmAction": "Activar", "options": { "never": "Nunca", "1-day": "Después de 1 día", From 0e0438badaea1db2bd48a01253b9517524ae2611 Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Tue, 11 Aug 2026 11:48:45 -0400 Subject: [PATCH 3/5] fix: revalidate automatic archive safety Co-authored-by: Goose --- src/app/AppShell.tsx | 19 +- .../stores/__tests__/chatSessionStore.test.ts | 29 +++ src/features/chat/stores/chatSessionStore.ts | 18 +- .../__tests__/useAutoArchiveSessions.test.ts | 228 ++++++++++++++++++ .../sessions/hooks/useAutoArchiveSessions.ts | 153 +++++++++--- 5 files changed, 407 insertions(+), 40 deletions(-) create mode 100644 src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 11eeceaf5..323b44f7f 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -3518,6 +3518,8 @@ export function AppShell({ sessionId: string, cleanupPolicy: ArchiveCleanupPolicy, deadlineMs?: number, + fallbackSession?: ChatSession, + revalidateBeforeMutation?: () => Promise, ) => { let releaseArchiveQueue!: () => void; const previousArchive = sessionArchiveQueueRef.current; @@ -3528,8 +3530,8 @@ export function AppShell({ try { const sessionStore = useChatSessionStore.getState(); - const session = sessionStore.getSession(sessionId); - if (!session) { + const session = sessionStore.getSession(sessionId) ?? fallbackSession; + if (!session || session.id !== sessionId) { return { ok: false as const, reason: "session_not_found" as const }; } @@ -3592,9 +3594,17 @@ export function AppShell({ if (preArchiveInterruption) { return { ok: false as const, reason: preArchiveInterruption }; } + if (revalidateBeforeMutation && !(await revalidateBeforeMutation())) { + return { + ok: false as const, + reason: "blocked_unsaved_changes" as const, + }; + } try { - await useChatSessionStore.getState().archiveSession(sessionId); + await useChatSessionStore + .getState() + .archiveSession(sessionId, fallbackSession); const homeWidgetState = useHomeWidgetStore.getState(); const pinnedWidget = homeWidgetState.instances.find( (instance) => @@ -3679,7 +3689,8 @@ export function AppShell({ ); const handleAutoArchiveChat = useCallback( - (sessionId: string) => archiveChat(sessionId, "reject"), + (session: ChatSession, revalidate: () => Promise) => + archiveChat(session.id, "reject", undefined, session, revalidate), [archiveChat], ); useAutoArchiveSessions(handleAutoArchiveChat); diff --git a/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 1f5fc181d..f7b5b4303 100644 --- a/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -287,6 +287,35 @@ describe("chatSessionStore", () => { expect(mocks.releaseSession).not.toHaveBeenCalled(); }); + it("archives a known paged-out session without materializing it", async () => { + const pagedOut = makeSession({ id: "paged-out" }); + + await useChatSessionStore + .getState() + .archiveSession(pagedOut.id, pagedOut); + + const state = useChatSessionStore.getState(); + expect(mocks.archiveSession).toHaveBeenCalledWith("paged-out"); + expect(state.getSession("paged-out")).toBeUndefined(); + expect(state.archiveMutationBySessionId["paged-out"]).toMatchObject({ + desiredState: "archived", + status: "succeeded", + }); + }); + + it("leaves no store state when a paged-out archive fails", async () => { + const pagedOut = makeSession({ id: "paged-out" }); + mocks.archiveSession.mockRejectedValue(new Error("backend down")); + + await expect( + useChatSessionStore.getState().archiveSession(pagedOut.id, pagedOut), + ).rejects.toThrow("backend down"); + + const state = useChatSessionStore.getState(); + expect(state.getSession("paged-out")).toBeUndefined(); + expect(state.archiveMutationBySessionId["paged-out"]).toBeUndefined(); + }); + it("does not release a windowed session when archiving", async () => { seedSession({ id: "session-1" }); useSessionWindowStore diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 137623a37..9271085c8 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -229,7 +229,7 @@ interface ChatSessionStoreActions { * rethrown. App-owned cleanup/navigation belongs in AppShell. * Throws {@link SessionNotFoundError} when the id matches no session. */ - archiveSession: (id: string) => Promise; + archiveSession: (id: string, fallbackSession?: ChatSession) => Promise; /** * Unarchive a session optimistically (clears `archivedAt`), then awaits the * backend call. On backend failure `archivedAt` rolls back and the error is @@ -348,7 +348,12 @@ function recordArchiveMutationSuccess( if (!currentMutation) { if (!state.sessions.some((candidate) => candidate.id === sessionId)) { - return state; + return { + archiveMutationBySessionId: { + ...state.archiveMutationBySessionId, + [sessionId]: completedSucceededMutation, + }, + }; } return { @@ -971,9 +976,12 @@ export const useChatSessionStore = create((set, get) => ({ releaseWindowedSession(id); }, - archiveSession: async (id) => { - const session = get().sessions.find((candidate) => candidate.id === id); - if (!session) { + archiveSession: async (id, fallbackSession) => { + const storedSession = get().sessions.find( + (candidate) => candidate.id === id, + ); + const session = storedSession ?? fallbackSession; + if (!session || session.id !== id) { throw new SessionNotFoundError(id); } const optimisticArchivedAt = new Date().toISOString(); diff --git a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts new file mode 100644 index 000000000..9c5962cd9 --- /dev/null +++ b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; +import { setAutoArchiveAfter } from "@/features/settings/lib/autoArchivePreference"; +import { runAutoArchiveSweep } from "../useAutoArchiveSessions"; + +const mocks = vi.hoisted(() => ({ + getLayout: vi.fn(), + getSessionInfo: vi.fn(), + loadAllSessions: vi.fn(), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpGetSessionInfo: (...args: unknown[]) => mocks.getSessionInfo(...args), +})); + +vi.mock("@/features/layout/api/layout", () => ({ + HOME_LAYOUT_ID: "home", + getLayout: (...args: unknown[]) => mocks.getLayout(...args), +})); + +vi.mock("@/features/chat/lib/sessionWorkspaceCleanup", () => ({ + loadAllSessionsForWorkspaceCleanup: (...args: unknown[]) => + mocks.loadAllSessions(...args), +})); + +function session(id: string, updatedAt = "2026-01-01T00:00:00.000Z") { + return { + id, + title: id, + createdAt: updatedAt, + updatedAt, + lastMessageAt: updatedAt, + messageCount: 1, + } satisfies ChatSession; +} + +function layout(sessionIds: string[] = []) { + return { + layoutId: "home", + itemRevision: 1, + cameraRevision: 1, + camera: { centerX: 0, centerY: 0, zoomBps: 10_000 }, + constraints: { + minCenter: -100, + maxCenter: 100, + minSize: 1, + maxSize: 100, + minZoomBps: 1, + maxZoomBps: 20_000, + maxTitleOverrideLength: 100, + maxItems: 100, + }, + items: sessionIds.map((sessionId, index) => ({ + id: `pin-${sessionId}`, + kind: "session" as const, + targetId: sessionId, + centerX: 0, + centerY: 0, + width: 1, + height: 1, + zIndex: index, + titleOverride: null, + })), + }; +} + +function resetStores() { + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + archiveMutationBySessionId: {}, + }); + useChatStore.setState({ + queuedMessageBySession: {}, + draftsBySession: {}, + nonEmptyDraftSessionIds: new Set(), + skillDraftsBySession: {}, + draftAttachmentsBySession: {}, + }); + useHomeWidgetStore.setState({ instances: [] }); +} + +describe("runAutoArchiveSweep", () => { + beforeEach(() => { + localStorage.clear(); + resetStores(); + setAutoArchiveAfter("7-days"); + mocks.getLayout.mockReset().mockResolvedValue(layout()); + mocks.getSessionInfo + .mockReset() + .mockImplementation((sessionId: string) => ({ + sessionId, + title: sessionId, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lastMessageAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + messageCount: 1, + userSetName: false, + })); + mocks.loadAllSessions.mockReset(); + }); + + it("does nothing while disabled", async () => { + setAutoArchiveAfter("never"); + const archiveSession = vi.fn(); + + await runAutoArchiveSweep({ archiveSession }); + + expect(mocks.loadAllSessions).not.toHaveBeenCalled(); + expect(archiveSession).not.toHaveBeenCalled(); + }); + + it("stops before later mutations when the user disables the setting", async () => { + const first = session("first"); + const second = session("second"); + mocks.loadAllSessions.mockResolvedValue([first, second]); + const archiveSession = vi.fn(async (candidate: ChatSession) => { + if (candidate.id === "first") setAutoArchiveAfter("never"); + return { ok: true }; + }); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).toHaveBeenCalledTimes(1); + expect(archiveSession).toHaveBeenCalledWith( + expect.objectContaining({ id: "first" }), + expect.any(Function), + ); + }); + + it("provides a final guard for changes while the archive transaction waits", async () => { + const stale = session("stale"); + mocks.loadAllSessions.mockResolvedValue([stale]); + const archiveSession = vi.fn( + async (_candidate: ChatSession, revalidate: () => Promise) => { + useChatSessionStore.setState({ activeSessionId: "stale" }); + expect(await revalidate()).toBe(false); + return { ok: false }; + }, + ); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).toHaveBeenCalledTimes(1); + }); + + it("skips a candidate with newer local activity than the refreshed backend row", async () => { + const stale = session("stale"); + mocks.loadAllSessions.mockResolvedValue([stale]); + useChatSessionStore.setState({ + sessions: [ + session("stale", new Date(Date.now() - 60 * 60 * 1000).toISOString()), + ], + }); + const archiveSession = vi.fn(); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).not.toHaveBeenCalled(); + }); + + it("skips a later candidate that becomes active", async () => { + const first = session("first"); + const second = session("second"); + mocks.loadAllSessions.mockResolvedValue([first, second]); + const archiveSession = vi.fn(async (candidate: ChatSession) => { + if (candidate.id === "first") { + useChatSessionStore.setState({ activeSessionId: "second" }); + } + return { ok: true }; + }); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).toHaveBeenCalledTimes(1); + expect(archiveSession).toHaveBeenCalledWith( + expect.objectContaining({ id: "first" }), + expect.any(Function), + ); + }); + + it("skips a later candidate that is pinned during the sweep", async () => { + const first = session("first"); + const second = session("second"); + mocks.loadAllSessions.mockResolvedValue([first, second]); + mocks.getLayout + .mockResolvedValueOnce(layout()) + .mockResolvedValueOnce(layout()) + .mockResolvedValueOnce(layout(["second"])); + const archiveSession = vi.fn().mockResolvedValue({ ok: true }); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).toHaveBeenCalledTimes(1); + expect(archiveSession).toHaveBeenCalledWith( + expect.objectContaining({ id: "first" }), + expect.any(Function), + ); + }); + + it.each([ + ["composer text", () => ({ nonEmptyDraftSessionIds: new Set(["stale"]) })], + [ + "queued message", + () => ({ + queuedMessageBySession: { stale: [{}] }, + }), + ], + ["skill draft", () => ({ skillDraftsBySession: { stale: [{}] } })], + [ + "draft attachment", + () => ({ draftAttachmentsBySession: { stale: [{}] } }), + ], + ])("preserves %s", async (_label, unsafeState) => { + const stale = session("stale"); + mocks.loadAllSessions.mockResolvedValue([stale]); + useChatStore.setState(unsafeState() as never); + const archiveSession = vi.fn(); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/sessions/hooks/useAutoArchiveSessions.ts b/src/features/sessions/hooks/useAutoArchiveSessions.ts index 29828873e..3ab3f34c7 100644 --- a/src/features/sessions/hooks/useAutoArchiveSessions.ts +++ b/src/features/sessions/hooks/useAutoArchiveSessions.ts @@ -1,16 +1,23 @@ import { useEffect } from "react"; -import { - AUTO_ARCHIVE_CHANGED_EVENT, - getAutoArchiveAfterMs, -} from "@/features/settings/lib/autoArchivePreference"; -import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; -import { getLayout, HOME_LAYOUT_ID } from "@/features/layout/api/layout"; +import { acpSessionToChatSession } from "@/features/chat/lib/acpSessionMapping"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { sessionActivityAt } from "@/features/chat/lib/sessionActivity"; import { loadAllSessionsForWorkspaceCleanup } from "@/features/chat/lib/sessionWorkspaceCleanup"; +import { acpGetSessionInfo } from "@/shared/api/acp"; import { type ChatSession, useChatSessionStore, } from "@/features/chat/stores/chatSessionStore"; -import { getAutoArchiveSessionCandidates } from "../lib/autoArchiveSessions"; +import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; +import { getLayout, HOME_LAYOUT_ID } from "@/features/layout/api/layout"; +import { + AUTO_ARCHIVE_CHANGED_EVENT, + getAutoArchiveAfterMs, +} from "@/features/settings/lib/autoArchivePreference"; +import { + getAutoArchiveSessionCandidates, + getPinnedChatSessionIds, +} from "../lib/autoArchiveSessions"; const AUTO_ARCHIVE_SWEEP_INTERVAL_MS = 60 * 60 * 1000; @@ -18,13 +25,98 @@ interface AutoArchiveResult { ok: boolean; } +type RevalidateAutoArchive = () => Promise; + interface RunAutoArchiveSweepOptions { - archiveSession: (sessionId: string) => Promise; + archiveSession: ( + session: ChatSession, + revalidate: RevalidateAutoArchive, + ) => Promise; nowMs?: number; } +interface PersistedChatPin { + type: "chatPin"; + state: { sessionId: string }; +} + let sweepPromise: Promise | null = null; +function persistedChatPins( + items: Awaited>["items"], +): PersistedChatPin[] { + return items + .filter((item) => item.kind === "session") + .map((item) => ({ type: "chatPin", state: { sessionId: item.targetId } })); +} + +function hasUnsentSessionInput(sessionId: string): boolean { + const chatState = useChatStore.getState(); + return ( + chatState.nonEmptyDraftSessionIds.has(sessionId) || + (chatState.queuedMessageBySession[sessionId]?.length ?? 0) > 0 || + (chatState.skillDraftsBySession[sessionId]?.length ?? 0) > 0 || + (chatState.draftAttachmentsBySession[sessionId]?.length ?? 0) > 0 + ); +} + +async function revalidateAutoArchiveCandidate( + originalSession: ChatSession, +): Promise { + const afterMs = getAutoArchiveAfterMs(); + if (afterMs === null) return null; + + const sessionStore = useChatSessionStore.getState(); + if (sessionStore.activeSessionId === originalSession.id) return null; + if (hasUnsentSessionInput(originalSession.id)) return null; + + const sessionInfo = await acpGetSessionInfo(originalSession.id); + const refreshedSession = sessionInfo + ? acpSessionToChatSession(sessionInfo) + : undefined; + const localSession = sessionStore.getSession(originalSession.id); + const latestSession = localSession + ? ({ ...refreshedSession, ...localSession } satisfies ChatSession) + : refreshedSession; + if (!latestSession || latestSession.archivedAt) return null; + + const activityMs = Math.max( + ...[refreshedSession, localSession] + .filter((session): session is ChatSession => session !== undefined) + .map((session) => Date.parse(sessionActivityAt(session))) + .filter(Number.isFinite), + ); + if ( + !Number.isFinite(activityMs) || + activityMs > Date.now() - afterMs || + getAutoArchiveAfterMs() === null + ) { + return null; + } + + // Re-read both durable and optimistic pin state immediately before the + // mutation. This closes the window where a user pins a later candidate while + // an earlier archive transaction is still running. + const latestHomeLayout = await getLayout(HOME_LAYOUT_ID); + if (getAutoArchiveAfterMs() === null) return null; + const pinnedSessionIds = getPinnedChatSessionIds([ + ...persistedChatPins(latestHomeLayout.items), + ...useHomeWidgetStore.getState().instances, + ]); + if (pinnedSessionIds.has(originalSession.id)) return null; + + const latestSessionStore = useChatSessionStore.getState(); + if ( + latestSessionStore.activeSessionId === originalSession.id || + hasUnsentSessionInput(originalSession.id) || + getAutoArchiveAfterMs() === null + ) { + return null; + } + + return latestSessionStore.getSession(originalSession.id) ?? latestSession; +} + export async function runAutoArchiveSweep({ archiveSession, nowMs = Date.now(), @@ -39,15 +131,9 @@ export async function runAutoArchiveSweep({ loadAllSessionsForWorkspaceCleanup(), getLayout(HOME_LAYOUT_ID), ]); - const persistedPinWidgets = homeLayout.items - .filter((item) => item.kind === "session") - .map((item) => ({ type: "chatPin", state: { sessionId: item.targetId } })); - const homeWidgets = [ - ...persistedPinWidgets, - ...useHomeWidgetStore.getState().instances, - ]; + if (getAutoArchiveAfterMs() === null) return; + const sessionStore = useChatSessionStore.getState(); - const activeSessionId = sessionStore.activeSessionId; const localSessionsById = new Map( sessionStore.sessions.map((session) => [session.id, session]), ); @@ -58,27 +144,32 @@ export async function runAutoArchiveSweep({ ? ({ ...session, ...localSession } satisfies ChatSession) : session; }), - homeWidgets, + homeWidgets: [ + ...persistedChatPins(homeLayout.items), + ...useHomeWidgetStore.getState().instances, + ], afterMs, nowMs, - }).filter((session) => session.id !== activeSessionId); - - // Use the same serialized archive transaction as manual actions. The - // noninteractive policy safely skips running chats and workspaces that would - // require confirmation rather than interrupting work or discarding files. - for (const session of candidates) { - // The complete ACP list can include a paged-out session that is not yet in - // the renderer store. Add its metadata so the shared archive transaction - // can inspect and mutate it exactly like a currently visible chat. - if (!useChatSessionStore.getState().getSession(session.id)) { - useChatSessionStore.getState().addSession(session); - } - await archiveSession(session.id); + }); + + // Use the same serialized archive transaction as manual actions. Revalidate + // every safety invariant at each turn because earlier candidates can spend + // time in Git inspection and cleanup while the user keeps interacting. + for (const candidate of candidates) { + const currentSession = await revalidateAutoArchiveCandidate(candidate); + if (!currentSession) continue; + await archiveSession(currentSession, async () => { + const revalidated = await revalidateAutoArchiveCandidate(currentSession); + return revalidated !== null; + }); } } export function useAutoArchiveSessions( - archiveSession: (sessionId: string) => Promise, + archiveSession: ( + session: ChatSession, + revalidate: RevalidateAutoArchive, + ) => Promise, ): void { useEffect(() => { let cancelled = false; From e0867bb8c5078bfaab6e53f4ffd632a1f77a7d78 Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Tue, 11 Aug 2026 12:51:50 -0400 Subject: [PATCH 4/5] fix: harden auto archive against runtime races Co-authored-by: Goose --- src/app/AppShell.tsx | 25 ++++++++++ .../__tests__/useAutoArchiveSessions.test.ts | 50 +++++++++++++++++++ .../sessions/hooks/useAutoArchiveSessions.ts | 19 +++++-- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 323b44f7f..ffe84515b 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -3565,6 +3565,21 @@ export function AppShell({ } } + // Noninteractive callers must inspect again at the last practical + // point before archiving. Git state can change while this transaction + // waits behind another archive or loads all sessions. + if (cleanupPolicy === "reject" && plans.length > 0) { + try { + plans = await inspectSessionWorkspaceCleanup(plans); + } catch (error) { + console.error("Failed to re-inspect session Git resources:", error); + return { + ok: false as const, + reason: "git_inspection_failed" as const, + }; + } + } + const wouldDiscardFiles = plans.some( wouldSessionWorkspaceCleanupDiscardFiles, ); @@ -3638,6 +3653,16 @@ export function AppShell({ | "timed_out" | null = null; try { + if (cleanupPolicy === "reject" && plans.length > 0) { + const finalPlans = await inspectSessionWorkspaceCleanup(plans); + if (finalPlans.some(wouldSessionWorkspaceCleanupDiscardFiles)) { + cleanupFailureReason = "workspace_cleanup_failed"; + throw new Error( + "Workspace changed after automatic archive preflight; preserving local files.", + ); + } + plans = finalPlans; + } await cleanupSessionWorkspaces(plans, { getInterruptionReason: () => getSessionArchiveInterruptionReason( diff --git a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts index 9c5962cd9..f2e104399 100644 --- a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts +++ b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatStore } from "@/features/chat/stores/chatStore"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; import { setAutoArchiveAfter } from "@/features/settings/lib/autoArchivePreference"; import { runAutoArchiveSweep } from "../useAutoArchiveSessions"; @@ -79,7 +80,9 @@ function resetStores() { nonEmptyDraftSessionIds: new Set(), skillDraftsBySession: {}, draftAttachmentsBySession: {}, + hasHydratedMessageQueues: true, }); + useSessionWindowStore.getState().setSnapshot([]); useHomeWidgetStore.setState({ instances: [] }); } @@ -114,6 +117,37 @@ describe("runAutoArchiveSweep", () => { expect(archiveSession).not.toHaveBeenCalled(); }); + it("waits for persisted message queues to hydrate", async () => { + const stale = session("stale"); + mocks.loadAllSessions.mockResolvedValue([stale]); + useChatStore.setState({ hasHydratedMessageQueues: false }); + const archiveSession = vi.fn(); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).not.toHaveBeenCalled(); + }); + + it("skips sessions with a pending archive-state mutation", async () => { + const stale = session("stale"); + mocks.loadAllSessions.mockResolvedValue([stale]); + useChatSessionStore.setState({ + sessions: [stale], + archiveMutationBySessionId: { + stale: { + operationId: 1, + desiredState: "unarchived", + status: "pending", + }, + }, + }); + const archiveSession = vi.fn(); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).not.toHaveBeenCalled(); + }); + it("stops before later mutations when the user disables the setting", async () => { const first = session("first"); const second = session("second"); @@ -203,6 +237,22 @@ describe("runAutoArchiveSweep", () => { }); it.each([ + [ + "a running session", + () => { + useChatStore.getState().setChatState("stale", "streaming"); + return {}; + }, + ], + [ + "a detached window", + () => { + useSessionWindowStore + .getState() + .setSnapshot([{ sessionId: "stale", windowLabel: "session:stale" }]); + return {}; + }, + ], ["composer text", () => ({ nonEmptyDraftSessionIds: new Set(["stale"]) })], [ "queued message", diff --git a/src/features/sessions/hooks/useAutoArchiveSessions.ts b/src/features/sessions/hooks/useAutoArchiveSessions.ts index 3ab3f34c7..bc6cf2ac1 100644 --- a/src/features/sessions/hooks/useAutoArchiveSessions.ts +++ b/src/features/sessions/hooks/useAutoArchiveSessions.ts @@ -1,7 +1,11 @@ import { useEffect } from "react"; import { acpSessionToChatSession } from "@/features/chat/lib/acpSessionMapping"; import { useChatStore } from "@/features/chat/stores/chatStore"; -import { sessionActivityAt } from "@/features/chat/lib/sessionActivity"; +import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; +import { + isSessionRunning, + sessionActivityAt, +} from "@/features/chat/lib/sessionActivity"; import { loadAllSessionsForWorkspaceCleanup } from "@/features/chat/lib/sessionWorkspaceCleanup"; import { acpGetSessionInfo } from "@/shared/api/acp"; import { @@ -50,9 +54,13 @@ function persistedChatPins( .map((item) => ({ type: "chatPin", state: { sessionId: item.targetId } })); } -function hasUnsentSessionInput(sessionId: string): boolean { +function hasLocalAutoArchiveBlocker(sessionId: string): boolean { const chatState = useChatStore.getState(); + const runtime = chatState.getSessionRuntime(sessionId); return ( + useSessionWindowStore.getState().isOpenInWindow(sessionId) || + isSessionRunning(runtime.chatState) || + runtime.isRunCancellationPending || chatState.nonEmptyDraftSessionIds.has(sessionId) || (chatState.queuedMessageBySession[sessionId]?.length ?? 0) > 0 || (chatState.skillDraftsBySession[sessionId]?.length ?? 0) > 0 || @@ -67,8 +75,10 @@ async function revalidateAutoArchiveCandidate( if (afterMs === null) return null; const sessionStore = useChatSessionStore.getState(); + if (!useChatStore.getState().hasHydratedMessageQueues) return null; if (sessionStore.activeSessionId === originalSession.id) return null; - if (hasUnsentSessionInput(originalSession.id)) return null; + if (sessionStore.archiveMutationBySessionId[originalSession.id]) return null; + if (hasLocalAutoArchiveBlocker(originalSession.id)) return null; const sessionInfo = await acpGetSessionInfo(originalSession.id); const refreshedSession = sessionInfo @@ -108,7 +118,7 @@ async function revalidateAutoArchiveCandidate( const latestSessionStore = useChatSessionStore.getState(); if ( latestSessionStore.activeSessionId === originalSession.id || - hasUnsentSessionInput(originalSession.id) || + hasLocalAutoArchiveBlocker(originalSession.id) || getAutoArchiveAfterMs() === null ) { return null; @@ -133,6 +143,7 @@ export async function runAutoArchiveSweep({ ]); if (getAutoArchiveAfterMs() === null) return; + if (!useChatStore.getState().hasHydratedMessageQueues) return; const sessionStore = useChatSessionStore.getState(); const localSessionsById = new Map( sessionStore.sessions.map((session) => [session.id, session]), From 0a22632c532bbd6ba174f3f32d7e78276fd4bec9 Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Wed, 12 Aug 2026 16:20:20 -0400 Subject: [PATCH 5/5] fix: preserve resources during automatic archive Co-authored-by: Goose --- src/app/AppShell.tsx | 29 +++----------- .../stores/__tests__/chatSessionStore.test.ts | 5 +-- src/features/chat/stores/chatSessionStore.ts | 12 +++--- .../__tests__/useAutoArchiveSessions.test.ts | 38 +++++++++++++++++++ .../sessions/hooks/useAutoArchiveSessions.ts | 24 ++++++++---- 5 files changed, 68 insertions(+), 40 deletions(-) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index ffe84515b..fd62f991b 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -3565,19 +3565,12 @@ export function AppShell({ } } - // Noninteractive callers must inspect again at the last practical - // point before archiving. Git state can change while this transaction - // waits behind another archive or loads all sessions. - if (cleanupPolicy === "reject" && plans.length > 0) { - try { - plans = await inspectSessionWorkspaceCleanup(plans); - } catch (error) { - console.error("Failed to re-inspect session Git resources:", error); - return { - ok: false as const, - reason: "git_inspection_failed" as const, - }; - } + // Automatic archiving must never remove a worktree or branch. A + // renderer-side status check cannot make a subsequent force-delete + // atomic with respect to editor or process writes, so preserve all Git + // resources and let the user clean them up explicitly later. + if (revalidateBeforeMutation) { + plans = []; } const wouldDiscardFiles = plans.some( @@ -3653,16 +3646,6 @@ export function AppShell({ | "timed_out" | null = null; try { - if (cleanupPolicy === "reject" && plans.length > 0) { - const finalPlans = await inspectSessionWorkspaceCleanup(plans); - if (finalPlans.some(wouldSessionWorkspaceCleanupDiscardFiles)) { - cleanupFailureReason = "workspace_cleanup_failed"; - throw new Error( - "Workspace changed after automatic archive preflight; preserving local files.", - ); - } - plans = finalPlans; - } await cleanupSessionWorkspaces(plans, { getInterruptionReason: () => getSessionArchiveInterruptionReason( diff --git a/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.test.ts index f7b5b4303..61c3842fb 100644 --- a/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -297,10 +297,7 @@ describe("chatSessionStore", () => { const state = useChatSessionStore.getState(); expect(mocks.archiveSession).toHaveBeenCalledWith("paged-out"); expect(state.getSession("paged-out")).toBeUndefined(); - expect(state.archiveMutationBySessionId["paged-out"]).toMatchObject({ - desiredState: "archived", - status: "succeeded", - }); + expect(state.archiveMutationBySessionId["paged-out"]).toBeUndefined(); }); it("leaves no store state when a paged-out archive fails", async () => { diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 9271085c8..811c297bd 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -348,12 +348,7 @@ function recordArchiveMutationSuccess( if (!currentMutation) { if (!state.sessions.some((candidate) => candidate.id === sessionId)) { - return { - archiveMutationBySessionId: { - ...state.archiveMutationBySessionId, - [sessionId]: completedSucceededMutation, - }, - }; + return state; } return { @@ -374,6 +369,11 @@ function recordArchiveMutationSuccess( } if (currentMutation.operationId === completedMutation.operationId) { + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + const { [sessionId]: _completed, ...archiveMutationBySessionId } = + state.archiveMutationBySessionId; + return { archiveMutationBySessionId }; + } return { archiveMutationBySessionId: { ...state.archiveMutationBySessionId, diff --git a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts index f2e104399..543c0cd3e 100644 --- a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts +++ b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts @@ -128,6 +128,17 @@ describe("runAutoArchiveSweep", () => { expect(archiveSession).not.toHaveBeenCalled(); }); + it("waits for the detached-window snapshot to hydrate", async () => { + const stale = session("stale"); + mocks.loadAllSessions.mockResolvedValue([stale]); + useSessionWindowStore.setState({ hasLoadedSnapshot: false }); + const archiveSession = vi.fn(); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).not.toHaveBeenCalled(); + }); + it("skips sessions with a pending archive-state mutation", async () => { const stale = session("stale"); mocks.loadAllSessions.mockResolvedValue([stale]); @@ -166,6 +177,33 @@ describe("runAutoArchiveSweep", () => { ); }); + it("continues after one candidate fails revalidation", async () => { + const first = session("first"); + const second = session("second"); + mocks.loadAllSessions.mockResolvedValue([first, second]); + mocks.getSessionInfo + .mockRejectedValueOnce(new Error("session disappeared")) + .mockImplementation((sessionId: string) => ({ + sessionId, + title: sessionId, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lastMessageAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + messageCount: 1, + userSetName: false, + })); + const archiveSession = vi.fn().mockResolvedValue({ ok: true }); + + await runAutoArchiveSweep({ archiveSession }); + + expect(archiveSession).toHaveBeenCalledTimes(1); + expect(archiveSession).toHaveBeenCalledWith( + expect.objectContaining({ id: "second" }), + expect.any(Function), + ); + }); + it("provides a final guard for changes while the archive transaction waits", async () => { const stale = session("stale"); mocks.loadAllSessions.mockResolvedValue([stale]); diff --git a/src/features/sessions/hooks/useAutoArchiveSessions.ts b/src/features/sessions/hooks/useAutoArchiveSessions.ts index bc6cf2ac1..134cc1952 100644 --- a/src/features/sessions/hooks/useAutoArchiveSessions.ts +++ b/src/features/sessions/hooks/useAutoArchiveSessions.ts @@ -56,9 +56,11 @@ function persistedChatPins( function hasLocalAutoArchiveBlocker(sessionId: string): boolean { const chatState = useChatStore.getState(); + const windowState = useSessionWindowStore.getState(); const runtime = chatState.getSessionRuntime(sessionId); return ( - useSessionWindowStore.getState().isOpenInWindow(sessionId) || + !windowState.hasLoadedSnapshot || + windowState.isOpenInWindow(sessionId) || isSessionRunning(runtime.chatState) || runtime.isRunCancellationPending || chatState.nonEmptyDraftSessionIds.has(sessionId) || @@ -167,12 +169,20 @@ export async function runAutoArchiveSweep({ // every safety invariant at each turn because earlier candidates can spend // time in Git inspection and cleanup while the user keeps interacting. for (const candidate of candidates) { - const currentSession = await revalidateAutoArchiveCandidate(candidate); - if (!currentSession) continue; - await archiveSession(currentSession, async () => { - const revalidated = await revalidateAutoArchiveCandidate(currentSession); - return revalidated !== null; - }); + try { + const currentSession = await revalidateAutoArchiveCandidate(candidate); + if (!currentSession) continue; + await archiveSession(currentSession, async () => { + const revalidated = + await revalidateAutoArchiveCandidate(currentSession); + return revalidated !== null; + }); + } catch (error) { + console.error( + `Failed to automatically archive chat ${candidate.id}:`, + error, + ); + } } }