Keep New Workspace prompts when switching projects or hosts (#2036)

* fix(app): preserve New Workspace prompts across target changes

The New Workspace draft was keyed by the selected host and project, so changing either target replaced the visible prompt with another draft scope. Treat the screen as one persistent draft surface and migrate the newest active legacy draft.

* test(app): express draft persistence as user actions

* fix(app): clear stale picker PR context

* fix(app): persist picker attachment ownership

* fix(app): keep workspace target context safe
This commit is contained in:
Mohamed Boudra
2026-07-13 15:36:48 +02:00
committed by GitHub
parent 218097b7cc
commit 13d6ad598e
11 changed files with 462 additions and 79 deletions
@@ -1,11 +1,60 @@
import { describe, expect, it } from "vitest";
import type { ComposerAttachment } from "@/attachments/types";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { migratePersistedState, type MigrateLegacyImages } from "./migration";
import { isAttachmentMetadata } from "./state";
import { isAttachmentMetadata, type DraftRecord } from "./state";
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
images.filter(isAttachmentMetadata);
function activeDraft(
text: string,
updatedAt: number,
attachments: UserComposerAttachment[] = [],
): DraftRecord {
return {
input: { text, attachments },
lifecycle: "active",
updatedAt,
version: 1,
};
}
function githubIssueAttachment(
number: number,
): Extract<UserComposerAttachment, { kind: "github_issue" }> {
return {
kind: "github_issue",
item: {
kind: "issue",
number,
title: `Review item ${number}`,
url: `https://example.com/issues/${number}`,
state: "open",
body: null,
labels: [],
},
};
}
function githubPrAttachment(
number: number,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return {
kind: "github_pr",
item: {
kind: "pr",
number,
title: `Review item ${number}`,
url: `https://example.com/pulls/${number}`,
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/legacy",
},
};
}
function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "review" }> {
return {
kind: "review",
@@ -47,6 +96,57 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie
}
describe("draft-store migration", () => {
it("promotes the newest legacy New Workspace draft into the singleton surface", async () => {
const forkDraft = activeDraft("fork context", 1700000000003);
const agentDraft = activeDraft("agent prompt", 1700000000004);
const migrated = await migratePersistedState(
{
drafts: {
"new-workspace:server-a:/project/older": activeDraft(
"older new workspace prompt",
1700000000001,
),
"new-workspace:server-b:/project/newer": activeDraft(
"newer new workspace prompt",
1700000000002,
),
"new-workspace:draft:fork-1": forkDraft,
"agent:server-a:agent-1": agentDraft,
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000005 },
);
expect(migrated.drafts).toEqual({
"new-workspace": activeDraft("newer new workspace prompt", 1700000000002),
"new-workspace:draft:fork-1": forkDraft,
"agent:server-a:agent-1": agentDraft,
});
});
it("drops unowned checkout PR context when promoting a scoped New Workspace draft", async () => {
const issue = githubIssueAttachment(101);
const migrated = await migratePersistedState(
{
drafts: {
"new-workspace:server-a:/project/a": activeDraft("keep the prompt", 2, [
issue,
githubPrAttachment(202),
]),
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 3 },
);
expect(migrated.drafts["new-workspace"]?.input).toEqual({
text: "keep the prompt",
attachments: [issue],
});
});
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
const migrated = await migratePersistedState(
{