From 22f50d4dfa6f7f4795f737e2994283e04c75d592 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:10:04 +0000 Subject: [PATCH] fix(app): preserve unsent drafts when closing agent tabs (#9079) Archiving removes an agent from the active directory without deleting the conversation. Preserve its local composer draft there too, and clear it only on explicit entity removal. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/archive-tab.spec.ts | 33 +++++++++++++++++++ .../app/e2e/support/helpers/archive-tab.ts | 9 +++++ .../directory-sync/agent-replica.test.ts | 18 +++++++++- .../directory-sync/internal/agent-store.ts | 6 ++-- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/browser/archive-tab.spec.ts b/packages/app/e2e/browser/archive-tab.spec.ts index 6ee80ddf1e..0dd5641184 100644 --- a/packages/app/e2e/browser/archive-tab.spec.ts +++ b/packages/app/e2e/browser/archive-tab.spec.ts @@ -6,6 +6,7 @@ import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { + closeActiveAgentWithShortcut, archiveAgentFromDaemon, archiveAgentFromSessions, clickSessionRow, @@ -20,8 +21,12 @@ import { openWorkspaceWithAgents, primeAdditionalPage, resetSeededPageState, + unarchiveAgentFromConversation, reloadWorkspace, } from "../support/helpers/archive-tab"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer"; +import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream"; import { expectAgentTabActive } from "../support/helpers/launcher"; test.describe("Archive tab reconciliation", () => { @@ -51,6 +56,34 @@ test.describe("Archive tab reconciliation", () => { await tempRepo?.cleanup(); }); + test("restoring a closed agent retains its unsent composer draft after reload", async ({ + page, + }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "archived-composer-", + title: "Restore unsent draft", + }); + try { + await openAgentRoute(page, agent); + await submitMessage(page, "A completed conversation before closing"); + await awaitAssistantMessage(page); + await expectAgentIdle(page); + await fillComposerDraft(page, "Keep this unsent message after closing"); + await expectComposerDraft(page, "Keep this unsent message after closing"); + await closeActiveAgentWithShortcut(page, agent.agentId); + await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull(); + await page.reload(); + await openSessions(page); + await expectSessionRowArchived(page, "Restore unsent draft"); + await clickSessionRow(page, "Restore unsent draft"); + await expectArchivedAgentFocused(page, agent.agentId); + await unarchiveAgentFromConversation(page); + await expectComposerDraft(page, "Keep this unsent message after closing"); + } finally { + await agent.cleanup(); + } + }); + test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => { const archived = await createIdleAgent(client, { cwd: tempRepo.path, diff --git a/packages/app/e2e/support/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts index cf9c43fcf3..97291d8951 100644 --- a/packages/app/e2e/support/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome( await expectWorkspaceTabVisible(page, input.survivingAgentId); } +export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise { + await page.keyboard.press("Alt+Shift+w"); + await expectWorkspaceTabHidden(page, agentId); +} + export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise { const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true, @@ -237,6 +242,10 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P }); } +export async function unarchiveAgentFromConversation(page: Page): Promise { + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); +} + export async function reloadWorkspace(page: Page, workspaceId: string): Promise { const serverId = getServerId(); await page.goto(buildHostWorkspaceRoute(serverId, workspaceId)); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 03fc0632a9..30d2053279 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -8,6 +8,8 @@ import { } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import { useDraftStore } from "@/stores/draft-store"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { @@ -58,7 +60,7 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { describe("AgentDirectoryReplica", () => { it.each(["delta", "snapshot"] as const)( - "does not erase freshly fetched history when the active directory removes it by %s", + "preserves history and unsent drafts when the active directory removes an agent by %s", (removal) => { const serverId = `agent-replica-history-${removal}`; const store = useSessionStore.getState(); @@ -68,6 +70,18 @@ describe("AgentDirectoryReplica", () => { () => undefined, () => undefined, ); + const draftKey = buildDraftStoreKey({ serverId, agentId: "agent" }); + const draft = { + text: "Keep this unsent draft", + attachments: [ + { + kind: "workspace_file" as const, + path: "README.md", + selection: { kind: "whole_file" as const }, + }, + ], + }; + useDraftStore.getState().saveDraftInput({ draftKey, draft }); const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" }; replica.submitTimelineAgent(replica.captureTimeline("agent"), archived); store.applyAgentTimelineResponseState(serverId, "agent", { @@ -93,7 +107,9 @@ describe("AgentDirectoryReplica", () => { status: "synced", items: [{ text: "server history" }], }); + expect(useDraftStore.getState().getDraftInput(draftKey)).toEqual(draft); replica.remove("agent"); + expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined(); expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ status: "cold", }); diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 6be4b5e9a5..1b3f24bcd6 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -190,9 +190,6 @@ export class AgentStoreProjection { agentLastActivity.delete(agentId); return { ...state, agentLastActivity }; }); - useDraftStore.getState().clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), - }); const initKey = getInitKey(this.serverId, agentId); if (getInitDeferred(initKey)) { rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); @@ -201,6 +198,9 @@ export class AgentStoreProjection { remove(agentId: string): void { this.removeFromDirectory(agentId); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), + }); // Only entity deletion destroys the transcript. A filtered active-list // response can arrive after a fresh history response for an archived agent. const store = useSessionStore.getState();