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

View File

@@ -181,6 +181,21 @@ export async function expectNewWorkspaceProjectSelected(
await expect(projectPicker).toContainText(projectDisplayName); await expect(projectPicker).toContainText(projectDisplayName);
} }
export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(draft);
}
export async function expectNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue(draft);
}
export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise<void> {
await page.getByTestId("host-picker-trigger").click();
await page.getByText(hostLabel, { exact: true }).click();
}
export async function submitNewWorkspacePrompt( export async function submitNewWorkspacePrompt(
page: Page, page: Page,
prompt = "Hello from e2e", prompt = "Hello from e2e",

View File

@@ -0,0 +1,88 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
const DRAFT = `Please investigate the workspace startup failure.
Trace the request from the app through the daemon, preserve the existing behavior, and explain the root cause before making changes.`;
test.describe("New workspace composer draft", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the draft when the project changes", async ({ page }) => {
const firstProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-project-a-",
});
const secondProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-project-b-",
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectDisplayName: secondProject.projectDisplayName,
});
await expectNewWorkspaceDraft(page, DRAFT);
} finally {
await secondProject.cleanup();
await firstProject.cleanup();
}
});
test("keeps the draft when the host changes", async ({ page }) => {
const project: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-host-",
});
const secondaryServerId = "new-workspace-draft-secondary-host";
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "Primary host",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: secondaryServerId,
label: "Secondary host",
endpoint: "127.0.0.1:9",
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceHost(page, "Secondary host");
await expectNewWorkspaceDraft(page, DRAFT);
} finally {
await project.cleanup();
}
});
});

View File

@@ -83,11 +83,17 @@ export interface ChatHistoryContextAttachment {
}; };
} }
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type UserComposerAttachment = export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata } | { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment } | { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem } | { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem }; | {
kind: "github_pr";
item: GitHubSearchItem;
owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
};
export type WorkspaceComposerAttachment = export type WorkspaceComposerAttachment =
| { | {

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types"; import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; import {
clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem { function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return { return {
@@ -19,8 +23,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
function prAttachment( function prAttachment(
item: GitHubSearchItem, item: GitHubSearchItem,
owner?: "new-workspace-picker",
): Extract<UserComposerAttachment, { kind: "github_pr" }> { ): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item }; return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
} }
function issueAttachment(number: number): UserComposerAttachment { function issueAttachment(number: number): UserComposerAttachment {
@@ -43,57 +48,88 @@ describe("syncPickerPrAttachment", () => {
const pr = makePrItem(202, "Refactor picker"); const pr = makePrItem(202, "Refactor picker");
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [], attachments: [],
previousPickerPrNumber: null,
item: { kind: "github-pr", item: pr }, item: { kind: "github-pr", item: pr },
}); });
expect(result.attachedPrNumber).toBe(202); expect(result).toEqual([prAttachment(pr, "new-workspace-picker")]);
expect(result.attachments).toEqual([prAttachment(pr)]);
}); });
it("selects a branch without modifying attachments when no previous picker PR", () => { it("selects a branch without modifying attachments when no previous picker PR", () => {
const issue = issueAttachment(44); const issue = issueAttachment(44);
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [issue], attachments: [issue],
previousPickerPrNumber: null,
item: { kind: "branch", name: "dev" }, item: { kind: "branch", name: "dev" },
}); });
expect(result.attachedPrNumber).toBeNull(); expect(result).toEqual([issue]);
expect(result.attachments).toEqual([issue]);
}); });
it("replaces the previous picker PR when a different PR is selected", () => { it("replaces the previous picker PR when a different PR is selected", () => {
const prA = makePrItem(202, "Refactor picker", "feature/picker"); const prA = makePrItem(202, "Refactor picker", "feature/picker");
const prB = makePrItem(303, "Polish chip", "feature/chip"); const prB = makePrItem(303, "Polish chip", "feature/chip");
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [prAttachment(prA)], attachments: [prAttachment(prA, "new-workspace-picker")],
previousPickerPrNumber: 202,
item: { kind: "github-pr", item: prB }, item: { kind: "github-pr", item: prB },
}); });
expect(result.attachedPrNumber).toBe(303); expect(result).toEqual([prAttachment(prB, "new-workspace-picker")]);
expect(result.attachments).toEqual([prAttachment(prB)]);
}); });
it("removes the previous picker PR and adds no new attachment when a branch is selected", () => { it("removes the previous picker PR and adds no new attachment when a branch is selected", () => {
const pr = makePrItem(202, "Refactor picker"); const pr = makePrItem(202, "Refactor picker");
const issue = issueAttachment(44); const issue = issueAttachment(44);
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [issue, prAttachment(pr)], attachments: [issue, prAttachment(pr, "new-workspace-picker")],
previousPickerPrNumber: 202,
item: { kind: "branch", name: "dev" }, item: { kind: "branch", name: "dev" },
}); });
expect(result.attachedPrNumber).toBeNull(); expect(result).toEqual([issue]);
expect(result.attachments).toEqual([issue]);
}); });
it("does not duplicate a PR that was already manually attached by the user", () => { it("does not duplicate a PR that was already manually attached by the user", () => {
const pr = makePrItem(202, "Refactor picker"); const pr = makePrItem(202, "Refactor picker");
const result = syncPickerPrAttachment({ const result = syncPickerPrAttachment({
attachments: [prAttachment(pr)], attachments: [prAttachment(pr)],
previousPickerPrNumber: null,
item: { kind: "github-pr", item: pr }, item: { kind: "github-pr", item: pr },
}); });
expect(result.attachedPrNumber).toBeNull(); expect(result).toEqual([prAttachment(pr)]);
expect(result.attachments).toHaveLength(1); });
it("clears a persisted picker selection without removing user-added attachments", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR"));
const issue = issueAttachment(44);
const result = syncPickerPrAttachment({
attachments: [issue, pickerPr, manuallyAttachedPr],
item: null,
});
expect(result).toEqual([issue, manuallyAttachedPr]);
});
});
describe("clearPickerPrAttachmentForTargetChange", () => {
it("keeps the picker selection when the target is reselected", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const attachments = [pickerPr];
expect(
clearPickerPrAttachmentForTargetChange({
attachments,
currentTargetId: "server-a",
nextTargetId: "server-a",
}),
).toBe(attachments);
});
it("clears only the picker-owned PR when the target changes", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const manualPr = prAttachment(makePrItem(303, "Manual PR"));
expect(
clearPickerPrAttachmentForTargetChange({
attachments: [pickerPr, manualPr],
currentTargetId: "server-a",
nextTargetId: "server-b",
}),
).toEqual([manualPr]);
}); });
}); });

View File

@@ -1,37 +1,60 @@
import type { UserComposerAttachment } from "@/attachments/types"; import {
NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER,
type UserComposerAttachment,
} from "@/attachments/types";
import type { PickerItem } from "./new-workspace-picker-item"; import type { PickerItem } from "./new-workspace-picker-item";
// The picker "owns" at most one PR attachment at a time. When the user selects function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract<
// a different item the previously-owned PR is removed before the new one is added. UserComposerAttachment,
// User-added attachments for other PRs/issues are left untouched. { kind: "github_pr" }
> & {
owner: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
} {
return (
attachment.kind === "github_pr" && attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
);
}
// Ownership lives on the attachment because drafts outlive this component.
// The picker owns at most one PR; user-added PRs and issues remain untouched.
export function syncPickerPrAttachment(input: { export function syncPickerPrAttachment(input: {
attachments: UserComposerAttachment[]; attachments: UserComposerAttachment[];
previousPickerPrNumber: number | null; item: PickerItem | null;
item: PickerItem; }): UserComposerAttachment[] {
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } { const nextAttachments = input.attachments.filter(
let nextAttachments = input.attachments; (attachment) => !isPickerOwnedPrAttachment(attachment),
let attachedPrNumber: number | null = null;
if (input.previousPickerPrNumber !== null) {
nextAttachments = nextAttachments.filter(
(attachment) =>
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
); );
}
if (input.item.kind === "github-pr") { if (input.item?.kind === "github-pr") {
const selectedPr = input.item.item; const selectedPr = input.item.item;
const hasExistingPrAttachment = nextAttachments.some( const hasExistingPrAttachment = nextAttachments.some(
(attachment) => (attachment) =>
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number, attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
); );
if (!hasExistingPrAttachment) { if (!hasExistingPrAttachment) {
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }]; return [
attachedPrNumber = selectedPr.number; ...nextAttachments,
{
kind: "github_pr",
item: selectedPr,
owner: NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER,
},
];
} }
} }
return { attachments: nextAttachments, attachedPrNumber }; return nextAttachments;
}
export function clearPickerPrAttachmentForTargetChange(input: {
attachments: UserComposerAttachment[];
currentTargetId: string;
nextTargetId: string;
}): UserComposerAttachment[] {
if (input.currentTargetId === input.nextTargetId) {
return input.attachments;
}
return syncPickerPrAttachment({ attachments: input.attachments, item: null });
} }
export function findCheckoutHintPrAttachment(input: { export function findCheckoutHintPrAttachment(input: {

View File

@@ -50,7 +50,7 @@ import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import { useProjectPickerStore } from "@/stores/project-picker-store"; import { useProjectPickerStore } from "@/stores/project-picker-store";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
@@ -92,7 +92,11 @@ import {
type PickerCheckoutRequest, type PickerCheckoutRequest,
type PickerItem, type PickerItem,
} from "./new-workspace-picker-item"; } from "./new-workspace-picker-item";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; import {
clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import { import {
resolveNewWorkspaceAutomaticServerId, resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId, resolveNewWorkspaceInitialServerId,
@@ -179,7 +183,6 @@ interface PickerOptionData {
interface PickerSelection { interface PickerSelection {
item: PickerItem; item: PickerItem;
attachedPrNumber: number | null;
} }
const BRANCH_OPTION_PREFIX = "branch:"; const BRANCH_OPTION_PREFIX = "branch:";
@@ -786,18 +789,6 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
return [styles.content, styles.contentCentered]; return [styles.content, styles.contentCentered];
} }
function buildNewWorkspaceDraftKey(input: {
selectedServerId: string;
selectedSourceDirectory: string | null;
draftId?: string;
}): string {
const explicitDraftId = input.draftId?.trim();
if (explicitDraftId) {
return `new-workspace:draft:${explicitDraftId}`;
}
return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`;
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null { function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null; if (!selection) return null;
return selection.item; return selection.item;
@@ -1682,11 +1673,7 @@ export function NewWorkspaceScreen({
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
projects: projectIconTargets, projects: projectIconTargets,
}); });
const draftKey = buildNewWorkspaceDraftKey({ const draftKey = buildNewWorkspaceDraftKey(draftId);
selectedServerId,
selectedSourceDirectory,
draftId,
});
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo( const visibleDraftContextScopeKeys = useMemo(
@@ -1801,22 +1788,16 @@ export function NewWorkspaceScreen({
}, [selectedItem]); }, [selectedItem]);
const selectPickerItem = useCallback( const selectPickerItem = useCallback(
(item: PickerItem) => { (item: PickerItem) => {
const next = syncPickerPrAttachment({ const nextAttachments = syncPickerPrAttachment({
attachments: chatDraft.attachments, attachments: chatDraft.attachments,
previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null,
item, item,
}); });
setManualPickerSelection({ setManualPickerSelection({ item });
item, chatDraft.setAttachments(nextAttachments);
attachedPrNumber: next.attachedPrNumber,
});
if (next.attachments !== chatDraft.attachments) {
chatDraft.setAttachments(next.attachments);
}
setPickerOpen(false); setPickerOpen(false);
}, },
[chatDraft, manualPickerSelection?.attachedPrNumber], [chatDraft],
); );
const handleSelectOption = useCallback( const handleSelectOption = useCallback(
@@ -1828,6 +1809,20 @@ export function NewWorkspaceScreen({
[itemById, selectPickerItem], [itemById, selectPickerItem],
); );
const clearManualPickerSelectionForTargetChange = useCallback(
(currentTargetId: string, nextTargetId: string) => {
const nextAttachments = clearPickerPrAttachmentForTargetChange({
attachments: chatDraft.attachments,
currentTargetId,
nextTargetId,
});
if (nextAttachments === chatDraft.attachments) return;
chatDraft.setAttachments(nextAttachments);
setManualPickerSelection(null);
},
[chatDraft],
);
const handleSelectProjectOption = useCallback( const handleSelectProjectOption = useCallback(
(id: string) => { (id: string) => {
// selectProjectOption enforces selectability (worktree-only when // selectProjectOption enforces selectability (worktree-only when
@@ -1835,9 +1830,17 @@ export function NewWorkspaceScreen({
// canCreateWorktree or non-git projects become unselectable. // canCreateWorktree or non-git projects become unselectable.
selectProjectOption(id); selectProjectOption(id);
setProjectPickerOpen(false); setProjectPickerOpen(false);
setManualPickerSelection(null); clearManualPickerSelectionForTargetChange(selectedProjectOptionId, id);
}, },
[selectProjectOption], [clearManualPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
);
const handleSelectWorkspaceHost = useCallback(
(id: string) => {
handleSelectHost(id);
clearManualPickerSelectionForTargetChange(selectedServerId, id);
},
[clearManualPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
); );
const handleAddProject = useCallback(() => { const handleAddProject = useCallback(() => {
@@ -2155,7 +2158,7 @@ export function NewWorkspaceScreen({
host: { host: {
allHosts, allHosts,
selectedServerId, selectedServerId,
onSelect: handleSelectHost, onSelect: handleSelectWorkspaceHost,
openState: hostPickerOpen, openState: hostPickerOpen,
onOpenChange: handleHostPickerOpenChange, onOpenChange: handleHostPickerOpenChange,
anchorRef: hostPickerAnchorRef, anchorRef: hostPickerAnchorRef,

View File

@@ -1,9 +1,27 @@
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
export const NEW_WORKSPACE_DRAFT_KEY = "new-workspace";
const NEW_WORKSPACE_FORK_DRAFT_PREFIX = `${NEW_WORKSPACE_DRAFT_KEY}:draft:`;
export function generateDraftId(): string { export function generateDraftId(): string {
return `draft_${generateMessageId()}`; return `draft_${generateMessageId()}`;
} }
export function buildNewWorkspaceDraftKey(draftId?: string): string {
const explicitDraftId = draftId?.trim();
if (explicitDraftId) {
return `${NEW_WORKSPACE_FORK_DRAFT_PREFIX}${explicitDraftId}`;
}
return NEW_WORKSPACE_DRAFT_KEY;
}
export function isLegacyNewWorkspaceDraftKey(draftKey: string): boolean {
return (
draftKey.startsWith(`${NEW_WORKSPACE_DRAFT_KEY}:`) &&
!draftKey.startsWith(NEW_WORKSPACE_FORK_DRAFT_PREFIX)
);
}
export function buildDraftStoreKey(input: { export function buildDraftStoreKey(input: {
serverId: string; serverId: string;
agentId: string; agentId: string;

View File

@@ -1,11 +1,60 @@
import { describe, expect, it } from "vitest"; 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 { migratePersistedState, type MigrateLegacyImages } from "./migration";
import { isAttachmentMetadata } from "./state"; import { isAttachmentMetadata, type DraftRecord } from "./state";
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
images.filter(isAttachmentMetadata); 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" }> { function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "review" }> {
return { return {
kind: "review", kind: "review",
@@ -47,6 +96,57 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie
} }
describe("draft-store migration", () => { 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 () => { it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
const migrated = await migratePersistedState( const migrated = await migratePersistedState(
{ {

View File

@@ -1,4 +1,5 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys";
import { import {
isAttachmentMetadata, isAttachmentMetadata,
isLegacyDraftImage, isLegacyDraftImage,
@@ -98,6 +99,46 @@ async function buildMigratedDraftRecord(
}; };
} }
function migrateNewWorkspaceDraftKeys(
drafts: Record<string, DraftRecord>,
): Record<string, DraftRecord> {
const legacyEntries = Object.entries(drafts).filter(([draftKey]) =>
isLegacyNewWorkspaceDraftKey(draftKey),
);
if (legacyEntries.length === 0) {
return drafts;
}
const nextDrafts = { ...drafts };
for (const [draftKey] of legacyEntries) {
delete nextDrafts[draftKey];
}
if (nextDrafts[NEW_WORKSPACE_DRAFT_KEY]) {
return nextDrafts;
}
const newestActiveDraft = legacyEntries
.map(([, draft]) => draft)
.filter((draft) => draft.lifecycle === "active")
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
if (newestActiveDraft) {
// Legacy scoped drafts did not record whether a PR came from the Base
// picker. That checkout context is unsafe to carry onto a global surface.
nextDrafts[NEW_WORKSPACE_DRAFT_KEY] = {
...newestActiveDraft,
input: {
...newestActiveDraft.input,
attachments: newestActiveDraft.input.attachments.filter(
(attachment) => attachment.kind !== "github_pr",
),
},
};
}
return nextDrafts;
}
export async function migratePersistedState( export async function migratePersistedState(
state: unknown, state: unknown,
ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number },
@@ -129,7 +170,8 @@ export async function migratePersistedState(
} }
return { return {
drafts: nextDrafts, // COMPAT(newWorkspaceDraftSingleton): migrated in v0.1.108; remove after 2027-01-13.
drafts: migrateNewWorkspaceDraftKeys(nextDrafts),
createModalDraft, createModalDraft,
}; };
} }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyClearDraftRecord, pruneFinalizedDraftRecords } from "./state"; import { applyClearDraftRecord, pruneFinalizedDraftRecords, toDraftInputIfReady } from "./state";
describe("draft-store lifecycle", () => { describe("draft-store lifecycle", () => {
it("prunes finalized tombstones after TTL", () => { it("prunes finalized tombstones after TTL", () => {
@@ -69,3 +69,35 @@ describe("draft-store lifecycle", () => {
}); });
}); });
}); });
describe("draft-store normalization", () => {
it("preserves New Workspace picker ownership when hydrating a draft", () => {
const pickerAttachment = {
kind: "github_pr" as const,
owner: "new-workspace-picker" as const,
item: {
kind: "pr" as const,
number: 202,
title: "Persist picker ownership",
url: "https://example.com/pull/202",
state: "open" as const,
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/picker-ownership",
},
};
expect(
toDraftInputIfReady({
input: { text: "Keep this prompt", attachments: [pickerAttachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({
text: "Keep this prompt",
attachments: [pickerAttachment],
});
});
});

View File

@@ -1,7 +1,11 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; import {
NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER,
type AttachmentMetadata,
type UserComposerAttachment,
} from "@/attachments/types";
import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages"; import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 4; export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
export interface LegacyDraftImage { export interface LegacyDraftImage {
@@ -82,6 +86,13 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
if (record.kind !== "github_issue" && record.kind !== "github_pr") { if (record.kind !== "github_issue" && record.kind !== "github_pr") {
return false; return false;
} }
if (
record.kind === "github_pr" &&
record.owner !== undefined &&
record.owner !== NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
) {
return false;
}
return GitHubSearchItemSchema.safeParse(record.item).success; return GitHubSearchItemSchema.safeParse(record.item).success;
} }
@@ -94,6 +105,15 @@ export function normalizeComposerAttachment(
metadata: normalizeAttachmentMetadata(attachment.metadata), metadata: normalizeAttachmentMetadata(attachment.metadata),
}; };
} }
if (attachment.kind === "github_pr") {
return {
kind: "github_pr",
item: attachment.item,
...(attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
? { owner: attachment.owner }
: {}),
};
}
return attachment; return attachment;
} }