mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
|
||||
await page.keyboard.press("Alt+Shift+w");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
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<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user