diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index e404682a2..b907017f1 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -181,6 +181,21 @@ export async function expectNewWorkspaceProjectSelected( await expect(projectPicker).toContainText(projectDisplayName); } +export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise { + 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 { + await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue(draft); +} + +export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise { + await page.getByTestId("host-picker-trigger").click(); + await page.getByText(hostLabel, { exact: true }).click(); +} + export async function submitNewWorkspacePrompt( page: Page, prompt = "Hello from e2e", diff --git a/packages/app/e2e/new-workspace-composer-draft.spec.ts b/packages/app/e2e/new-workspace-composer-draft.spec.ts new file mode 100644 index 000000000..3606fe7e0 --- /dev/null +++ b/packages/app/e2e/new-workspace-composer-draft.spec.ts @@ -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(); + } + }); +}); diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index eecb17766..82e447f92 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -83,11 +83,17 @@ export interface ChatHistoryContextAttachment { }; } +export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker"; + export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } | { 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 = | { diff --git a/packages/app/src/screens/new-workspace-picker-state.test.ts b/packages/app/src/screens/new-workspace-picker-state.test.ts index fa281b066..8d9a9e881 100644 --- a/packages/app/src/screens/new-workspace-picker-state.test.ts +++ b/packages/app/src/screens/new-workspace-picker-state.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; 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 { return { @@ -19,8 +23,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G function prAttachment( item: GitHubSearchItem, + owner?: "new-workspace-picker", ): Extract { - return { kind: "github_pr", item }; + return { kind: "github_pr", item, ...(owner ? { owner } : {}) }; } function issueAttachment(number: number): UserComposerAttachment { @@ -43,57 +48,88 @@ describe("syncPickerPrAttachment", () => { const pr = makePrItem(202, "Refactor picker"); const result = syncPickerPrAttachment({ attachments: [], - previousPickerPrNumber: null, item: { kind: "github-pr", item: pr }, }); - expect(result.attachedPrNumber).toBe(202); - expect(result.attachments).toEqual([prAttachment(pr)]); + expect(result).toEqual([prAttachment(pr, "new-workspace-picker")]); }); it("selects a branch without modifying attachments when no previous picker PR", () => { const issue = issueAttachment(44); const result = syncPickerPrAttachment({ attachments: [issue], - previousPickerPrNumber: null, item: { kind: "branch", name: "dev" }, }); - expect(result.attachedPrNumber).toBeNull(); - expect(result.attachments).toEqual([issue]); + expect(result).toEqual([issue]); }); it("replaces the previous picker PR when a different PR is selected", () => { const prA = makePrItem(202, "Refactor picker", "feature/picker"); const prB = makePrItem(303, "Polish chip", "feature/chip"); const result = syncPickerPrAttachment({ - attachments: [prAttachment(prA)], - previousPickerPrNumber: 202, + attachments: [prAttachment(prA, "new-workspace-picker")], item: { kind: "github-pr", item: prB }, }); - expect(result.attachedPrNumber).toBe(303); - expect(result.attachments).toEqual([prAttachment(prB)]); + expect(result).toEqual([prAttachment(prB, "new-workspace-picker")]); }); it("removes the previous picker PR and adds no new attachment when a branch is selected", () => { const pr = makePrItem(202, "Refactor picker"); const issue = issueAttachment(44); const result = syncPickerPrAttachment({ - attachments: [issue, prAttachment(pr)], - previousPickerPrNumber: 202, + attachments: [issue, prAttachment(pr, "new-workspace-picker")], item: { kind: "branch", name: "dev" }, }); - expect(result.attachedPrNumber).toBeNull(); - expect(result.attachments).toEqual([issue]); + expect(result).toEqual([issue]); }); it("does not duplicate a PR that was already manually attached by the user", () => { const pr = makePrItem(202, "Refactor picker"); const result = syncPickerPrAttachment({ attachments: [prAttachment(pr)], - previousPickerPrNumber: null, item: { kind: "github-pr", item: pr }, }); - expect(result.attachedPrNumber).toBeNull(); - expect(result.attachments).toHaveLength(1); + expect(result).toEqual([prAttachment(pr)]); + }); + + 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]); }); }); diff --git a/packages/app/src/screens/new-workspace-picker-state.ts b/packages/app/src/screens/new-workspace-picker-state.ts index c3a25452e..2caa8c588 100644 --- a/packages/app/src/screens/new-workspace-picker-state.ts +++ b/packages/app/src/screens/new-workspace-picker-state.ts @@ -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"; -// The picker "owns" at most one PR attachment at a time. When the user selects -// a different item the previously-owned PR is removed before the new one is added. -// User-added attachments for other PRs/issues are left untouched. +function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract< + UserComposerAttachment, + { 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: { attachments: UserComposerAttachment[]; - previousPickerPrNumber: number | null; - item: PickerItem; -}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } { - let nextAttachments = input.attachments; - let attachedPrNumber: number | null = null; + item: PickerItem | null; +}): UserComposerAttachment[] { + const nextAttachments = input.attachments.filter( + (attachment) => !isPickerOwnedPrAttachment(attachment), + ); - 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 hasExistingPrAttachment = nextAttachments.some( (attachment) => attachment.kind === "github_pr" && attachment.item.number === selectedPr.number, ); if (!hasExistingPrAttachment) { - nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }]; - attachedPrNumber = selectedPr.number; + return [ + ...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: { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index a5a3e3279..93eaa9496 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -50,7 +50,7 @@ import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store" import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; 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 { useProjectPickerStore } from "@/stores/project-picker-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; @@ -92,7 +92,11 @@ import { type PickerCheckoutRequest, type PickerItem, } from "./new-workspace-picker-item"; -import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; +import { + clearPickerPrAttachmentForTargetChange, + findCheckoutHintPrAttachment, + syncPickerPrAttachment, +} from "./new-workspace-picker-state"; import { resolveNewWorkspaceAutomaticServerId, resolveNewWorkspaceInitialServerId, @@ -179,7 +183,6 @@ interface PickerOptionData { interface PickerSelection { item: PickerItem; - attachedPrNumber: number | null; } const BRANCH_OPTION_PREFIX = "branch:"; @@ -786,18 +789,6 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { 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 { if (!selection) return null; return selection.item; @@ -1682,11 +1673,7 @@ export function NewWorkspaceScreen({ const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ projects: projectIconTargets, }); - const draftKey = buildNewWorkspaceDraftKey({ - selectedServerId, - selectedSourceDirectory, - draftId, - }); + const draftKey = buildNewWorkspaceDraftKey(draftId); const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); const visibleDraftContextScopeKeys = useMemo( @@ -1801,22 +1788,16 @@ export function NewWorkspaceScreen({ }, [selectedItem]); const selectPickerItem = useCallback( (item: PickerItem) => { - const next = syncPickerPrAttachment({ + const nextAttachments = syncPickerPrAttachment({ attachments: chatDraft.attachments, - previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null, item, }); - setManualPickerSelection({ - item, - attachedPrNumber: next.attachedPrNumber, - }); - if (next.attachments !== chatDraft.attachments) { - chatDraft.setAttachments(next.attachments); - } + setManualPickerSelection({ item }); + chatDraft.setAttachments(nextAttachments); setPickerOpen(false); }, - [chatDraft, manualPickerSelection?.attachedPrNumber], + [chatDraft], ); const handleSelectOption = useCallback( @@ -1828,6 +1809,20 @@ export function NewWorkspaceScreen({ [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( (id: string) => { // selectProjectOption enforces selectability (worktree-only when @@ -1835,9 +1830,17 @@ export function NewWorkspaceScreen({ // canCreateWorktree or non-git projects become unselectable. selectProjectOption(id); 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(() => { @@ -2155,7 +2158,7 @@ export function NewWorkspaceScreen({ host: { allHosts, selectedServerId, - onSelect: handleSelectHost, + onSelect: handleSelectWorkspaceHost, openState: hostPickerOpen, onOpenChange: handleHostPickerOpenChange, anchorRef: hostPickerAnchorRef, diff --git a/packages/app/src/stores/draft-keys.ts b/packages/app/src/stores/draft-keys.ts index a9b497016..68488600a 100644 --- a/packages/app/src/stores/draft-keys.ts +++ b/packages/app/src/stores/draft-keys.ts @@ -1,9 +1,27 @@ 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 { 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: { serverId: string; agentId: string; diff --git a/packages/app/src/stores/draft-store/migration.test.ts b/packages/app/src/stores/draft-store/migration.test.ts index 0ddee1fe0..f9b4e9832 100644 --- a/packages/app/src/stores/draft-store/migration.test.ts +++ b/packages/app/src/stores/draft-store/migration.test.ts @@ -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 { + 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 { + 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 { return { kind: "review", @@ -47,6 +96,57 @@ function workspaceReviewAttachment(): Extract { + 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( { diff --git a/packages/app/src/stores/draft-store/migration.ts b/packages/app/src/stores/draft-store/migration.ts index 015c1ca82..fc21087ca 100644 --- a/packages/app/src/stores/draft-store/migration.ts +++ b/packages/app/src/stores/draft-store/migration.ts @@ -1,4 +1,5 @@ import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; +import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys"; import { isAttachmentMetadata, isLegacyDraftImage, @@ -98,6 +99,46 @@ async function buildMigratedDraftRecord( }; } +function migrateNewWorkspaceDraftKeys( + drafts: Record, +): Record { + 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( state: unknown, ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, @@ -129,7 +170,8 @@ export async function migratePersistedState( } return { - drafts: nextDrafts, + // COMPAT(newWorkspaceDraftSingleton): migrated in v0.1.108; remove after 2027-01-13. + drafts: migrateNewWorkspaceDraftKeys(nextDrafts), createModalDraft, }; } diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index 996292d13..16aad750e 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyClearDraftRecord, pruneFinalizedDraftRecords } from "./state"; +import { applyClearDraftRecord, pruneFinalizedDraftRecords, toDraftInputIfReady } from "./state"; describe("draft-store lifecycle", () => { 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], + }); + }); +}); diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index 647fc59eb..cfed72b6a 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -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"; -export const DRAFT_STORE_VERSION = 4; +export const DRAFT_STORE_VERSION = 5; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; export interface LegacyDraftImage { @@ -82,6 +86,13 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA if (record.kind !== "github_issue" && record.kind !== "github_pr") { 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; } @@ -94,6 +105,15 @@ export function normalizeComposerAttachment( 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; }