mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -181,6 +181,21 @@ export async function expectNewWorkspaceProjectSelected(
|
||||
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(
|
||||
page: Page,
|
||||
prompt = "Hello from e2e",
|
||||
|
||||
88
packages/app/e2e/new-workspace-composer-draft.spec.ts
Normal file
88
packages/app/e2e/new-workspace-composer-draft.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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<UserComposerAttachment, { kind: "github_pr" }> {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<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(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user