diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 486f94304..fd62f991b 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, @@ -3517,6 +3518,8 @@ export function AppShell({ sessionId: string, cleanupPolicy: ArchiveCleanupPolicy, deadlineMs?: number, + fallbackSession?: ChatSession, + revalidateBeforeMutation?: () => Promise, ) => { let releaseArchiveQueue!: () => void; const previousArchive = sessionArchiveQueueRef.current; @@ -3527,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 }; } @@ -3562,6 +3565,14 @@ export function AppShell({ } } + // 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( wouldSessionWorkspaceCleanupDiscardFiles, ); @@ -3591,9 +3602,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) => @@ -3677,6 +3696,13 @@ export function AppShell({ [cleanupChatSession, confirmGitCleanup, setActiveSession, t], ); + const handleAutoArchiveChat = useCallback( + (session: ChatSession, revalidate: () => Promise) => + archiveChat(session.id, "reject", undefined, session, revalidate), + [archiveChat], + ); + useAutoArchiveSessions(handleAutoArchiveChat); + const handleArchiveChat = useCallback( (sessionId: string) => archiveChat(sessionId, "confirm"), [archiveChat], diff --git a/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 1f5fc181d..61c3842fb 100644 --- a/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -287,6 +287,32 @@ 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"]).toBeUndefined(); + }); + + 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..811c297bd 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 @@ -369,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, @@ -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..543c0cd3e --- /dev/null +++ b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts @@ -0,0 +1,316 @@ +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"; + +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: {}, + hasHydratedMessageQueues: true, + }); + useSessionWindowStore.getState().setSnapshot([]); + 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("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("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]); + 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"); + 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("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]); + 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([ + [ + "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", + () => ({ + 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 new file mode 100644 index 000000000..134cc1952 --- /dev/null +++ b/src/features/sessions/hooks/useAutoArchiveSessions.ts @@ -0,0 +1,232 @@ +import { useEffect } from "react"; +import { acpSessionToChatSession } from "@/features/chat/lib/acpSessionMapping"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +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 { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +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; + +interface AutoArchiveResult { + ok: boolean; +} + +type RevalidateAutoArchive = () => Promise; + +interface RunAutoArchiveSweepOptions { + 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 hasLocalAutoArchiveBlocker(sessionId: string): boolean { + const chatState = useChatStore.getState(); + const windowState = useSessionWindowStore.getState(); + const runtime = chatState.getSessionRuntime(sessionId); + return ( + !windowState.hasLoadedSnapshot || + windowState.isOpenInWindow(sessionId) || + isSessionRunning(runtime.chatState) || + runtime.isRunCancellationPending || + 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 (!useChatStore.getState().hasHydratedMessageQueues) return null; + if (sessionStore.activeSessionId === 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 + ? 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 || + hasLocalAutoArchiveBlocker(originalSession.id) || + getAutoArchiveAfterMs() === null + ) { + return null; + } + + return latestSessionStore.getSession(originalSession.id) ?? latestSession; +} + +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), + ]); + if (getAutoArchiveAfterMs() === null) return; + + if (!useChatStore.getState().hasHydratedMessageQueues) return; + const sessionStore = useChatSessionStore.getState(); + 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: [ + ...persistedChatPins(homeLayout.items), + ...useHomeWidgetStore.getState().instances, + ], + afterMs, + nowMs, + }); + + // 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) { + 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, + ); + } + } +} + +export function useAutoArchiveSessions( + archiveSession: ( + session: ChatSession, + revalidate: RevalidateAutoArchive, + ) => 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..94997e645 --- /dev/null +++ b/src/features/settings/lib/__tests__/autoArchivePreference.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AUTO_ARCHIVE_CHANGED_EVENT, + AUTO_ARCHIVE_CONSENT_STORAGE_KEY, + AUTO_ARCHIVE_STORAGE_KEY, + getAutoArchiveAfter, + getAutoArchiveAfterMs, + setAutoArchiveAfter, +} from "../autoArchivePreference"; + +describe("auto archive preference", () => { + afterEach(() => { + localStorage.clear(); + }); + + 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"); + }); + + 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(localStorage.getItem(AUTO_ARCHIVE_CONSENT_STORAGE_KEY)).toBe("true"); + 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..c0291c311 --- /dev/null +++ b/src/features/settings/lib/autoArchivePreference.ts @@ -0,0 +1,141 @@ +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" + | "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 { + // 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 { + 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; +} + +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. + } + window.dispatchEvent( + new CustomEvent(AUTO_ARCHIVE_CHANGED_EVENT, { detail: { value } }), + ); +} + +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; + +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..08ef0a2e9 --- /dev/null +++ b/src/features/settings/ui/AutoArchiveChatsSection.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +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 { 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", "common"]); + const { value, setValue } = useAutoArchivePreference(); + const [pendingValue, setPendingValue] = useState( + null, + ); + + function selectValue(nextValue: AutoArchiveAfter) { + if (nextValue === "never") { + setValue(nextValue); + return; + } + setPendingValue(nextValue); + } + + return ( + <> + + ( + + )} + /> + + + !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 new file mode 100644 index 000000000..988a75c1c --- /dev/null +++ b/src/features/settings/ui/__tests__/AutoArchiveChatsSection.test.tsx @@ -0,0 +1,79 @@ +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"; +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 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("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" }), + ); + await user.click( + screen.getByRole("option", { + 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 6570c8e75..70398b078 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -65,7 +65,23 @@ } }, "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.", + "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", + "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..7afa23ca9 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -65,7 +65,23 @@ } }, "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.", + "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", + "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 +355,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"