Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3517,6 +3518,8 @@ export function AppShell({
sessionId: string,
cleanupPolicy: ArchiveCleanupPolicy,
deadlineMs?: number,
fallbackSession?: ChatSession,
revalidateBeforeMutation?: () => Promise<boolean>,
) => {
let releaseArchiveQueue!: () => void;
const previousArchive = sessionArchiveQueueRef.current;
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -3677,6 +3696,13 @@ export function AppShell({
[cleanupChatSession, confirmGitCleanup, setActiveSession, t],
);

const handleAutoArchiveChat = useCallback(
(session: ChatSession, revalidate: () => Promise<boolean>) =>
archiveChat(session.id, "reject", undefined, session, revalidate),
[archiveChat],
);
useAutoArchiveSessions(handleAutoArchiveChat);

const handleArchiveChat = useCallback(
(sessionId: string) => archiveChat(sessionId, "confirm"),
[archiveChat],
Expand Down
26 changes: 26 additions & 0 deletions src/features/chat/stores/__tests__/chatSessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions src/features/chat/stores/chatSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
archiveSession: (id: string, fallbackSession?: ChatSession) => Promise<void>;
/**
* Unarchive a session optimistically (clears `archivedAt`), then awaits the
* backend call. On backend failure `archivedAt` rolls back and the error is
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -971,9 +976,12 @@ export const useChatSessionStore = create<ChatSessionStore>((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();
Expand Down
Loading