diff --git a/packages/app/src/screens/new-workspace-picker-state.test.ts b/packages/app/src/screens/new-workspace-picker-state.test.ts new file mode 100644 index 000000000..66f6995d6 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-state.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { syncPickerPrAttachment } from "./new-workspace-picker-state"; + +function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem { + return { + kind: "pr", + number, + title, + url: `https://example.com/pull/${number}`, + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName, + }; +} + +function prAttachment(item: GitHubSearchItem): UserComposerAttachment { + return { kind: "github_pr", item }; +} + +function issueAttachment(number: number): UserComposerAttachment { + return { + kind: "github_issue", + item: { + kind: "issue", + number, + title: `Issue ${number}`, + url: `https://example.com/issues/${number}`, + state: "open", + body: null, + labels: [], + }, + }; +} + +describe("syncPickerPrAttachment", () => { + it("selects a PR when no previous picker PR is set", () => { + 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)]); + }); + + 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]); + }); + + 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, + item: { kind: "github-pr", item: prB }, + }); + expect(result.attachedPrNumber).toBe(303); + expect(result.attachments).toEqual([prAttachment(prB)]); + }); + + 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, + item: { kind: "branch", name: "dev" }, + }); + expect(result.attachedPrNumber).toBeNull(); + expect(result.attachments).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); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-state.ts b/packages/app/src/screens/new-workspace-picker-state.ts new file mode 100644 index 000000000..d9aac0f49 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-state.ts @@ -0,0 +1,35 @@ +import 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. +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; + + if (input.previousPickerPrNumber !== null) { + nextAttachments = nextAttachments.filter( + (attachment) => + attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber, + ); + } + + 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 { attachments: nextAttachments, attachedPrNumber }; +} diff --git a/packages/app/src/screens/new-workspace-screen.test.tsx b/packages/app/src/screens/new-workspace-screen.test.tsx deleted file mode 100644 index c09b38d7a..000000000 --- a/packages/app/src/screens/new-workspace-screen.test.tsx +++ /dev/null @@ -1,982 +0,0 @@ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ComposerAttachment } from "@/attachments/types"; -import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client"; -import type { GitHubSearchItem } from "@server/shared/messages"; -import { NewWorkspaceScreen } from "./new-workspace-screen"; - -const { - theme, - mockClient, - mergeWorkspacesMock, - navigateMock, - saveDraftInputMock, - clearDraftInputMock, - queueDraftSubmissionMock, - createdAgent: _createdAgent, - createdWorkspace, - prItem, - prItemB, - issueItem, - initialAttachments, - initialDraftState, -} = vi.hoisted(() => { - const hoistedTheme = { - spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, - iconSize: { sm: 14, md: 18, lg: 22 }, - borderWidth: { 1: 1 }, - borderRadius: { md: 6, lg: 8, "2xl": 16 }, - fontSize: { xs: 11, sm: 13, base: 15, lg: 18 }, - fontWeight: { medium: "500" }, - shadow: { md: {} }, - colors: { - surface0: "#000", - surface1: "#111", - surface2: "#222", - foreground: "#fff", - foregroundMuted: "#aaa", - popoverForeground: "#fff", - border: "#555", - destructive: "#ff453a", - palette: { - zinc: { 600: "#52525b" }, - }, - }, - }; - - const hoistedPrItem: GitHubSearchItem = { - kind: "pr", - number: 202, - title: "Refactor picker", - url: "https://example.com/pull/202", - state: "open", - body: null, - labels: [], - baseRefName: "main", - headRefName: "feature/picker", - }; - - const hoistedPrItemB: GitHubSearchItem = { - kind: "pr", - number: 303, - title: "Polish composer chip", - url: "https://example.com/pull/303", - state: "open", - body: null, - labels: [], - baseRefName: "main", - headRefName: "feature/composer-chip", - }; - - const hoistedIssueItem: GitHubSearchItem = { - kind: "issue", - number: 44, - title: "Keep manual attachment", - url: "https://example.com/issues/44", - state: "open", - body: null, - labels: [], - }; - - const hoistedInitialAttachments: ComposerAttachment[] = []; - const hoistedInitialDraftState = { text: "" }; - - const hoistedCreatedWorkspace = { - id: "workspace-1", - workspaceDirectory: "/repo/.paseo/worktrees/workspace-1", - }; - - const hoistedCreatedAgent = { - id: "agent-1", - cwd: hoistedCreatedWorkspace.workspaceDirectory, - }; - - const hoistedMockClient = { - isConnected: true, - getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })), - getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })), - searchGitHub: vi.fn(async () => ({ - items: [hoistedPrItem, hoistedPrItemB], - githubFeaturesEnabled: true, - error: null, - })), - createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({ - workspace: hoistedCreatedWorkspace, - error: null, - })), - createAgent: vi.fn(async () => hoistedCreatedAgent), - }; - - return { - theme: hoistedTheme, - mockClient: hoistedMockClient, - mergeWorkspacesMock: vi.fn(), - navigateMock: vi.fn(), - saveDraftInputMock: vi.fn(), - clearDraftInputMock: vi.fn(), - queueDraftSubmissionMock: vi.fn(), - createdAgent: hoistedCreatedAgent, - createdWorkspace: hoistedCreatedWorkspace, - prItem: hoistedPrItem, - prItemB: hoistedPrItemB, - issueItem: hoistedIssueItem, - initialAttachments: hoistedInitialAttachments, - initialDraftState: hoistedInitialDraftState, - }; -}); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("@/constants/platform", () => ({ - isWeb: true, - isNative: false, -})); - -vi.mock("@/constants/layout", () => ({ - HEADER_INNER_HEIGHT: 48, - HEADER_INNER_HEIGHT_MOBILE: 56, - HEADER_TOP_PADDING_MOBILE: 8, - MAX_CONTENT_WIDTH: 900, - useIsCompactFormFactor: () => false, -})); - -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), -})); - -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - ChevronDown: createIcon("ChevronDown"), - GitBranch: createIcon("GitBranch"), - GitPullRequest: createIcon("GitPullRequest"), - }; -}); - -function flattenReanimatedStyle(style: unknown) { - if (!Array.isArray(style)) { - return style; - } - return Object.assign({}, ...style.filter(Boolean)); -} - -vi.mock("react-native-reanimated", () => ({ - default: { - View: ({ - testID, - style, - ...props - }: React.HTMLAttributes & { testID?: string; style?: unknown }) => { - const flattenedStyle = flattenReanimatedStyle(style); - return
; - }, - }, -})); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockClient, - useHostRuntimeIsConnected: () => true, -})); - -vi.mock("@/stores/session-store", () => ({ - useSessionStore: (selector: (state: unknown) => unknown) => - selector({ - mergeWorkspaces: mergeWorkspacesMock, - }), - normalizeWorkspaceDescriptor: (workspace: unknown) => workspace, -})); - -vi.mock("@/utils/agent-snapshots", () => ({ - normalizeAgentSnapshot: (agent: unknown) => agent, -})); - -vi.mock("@/utils/encode-images", () => ({ - encodeImages: vi.fn(async () => []), -})); - -vi.mock("@/utils/error-messages", () => ({ - toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), -})); - -vi.mock("@/utils/workspace-execution", () => ({ - requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({ - workspaceDirectory: input.workspace.workspaceDirectory, - }), -})); - -vi.mock("@/utils/workspace-navigation", () => ({ - navigateToPreparedWorkspaceTab: navigateMock, -})); - -vi.mock("@/stores/draft-keys", () => ({ - buildDraftStoreKey: ({ - serverId, - agentId, - draftId, - }: { - serverId: string; - agentId: string; - draftId?: string | null; - }) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`), - generateDraftId: () => "draft-new-workspace", -})); - -vi.mock("@/stores/draft-store", () => ({ - useDraftStore: { - getState: () => ({ - saveDraftInput: saveDraftInputMock, - clearDraftInput: clearDraftInputMock, - }), - }, -})); - -vi.mock("@/stores/workspace-draft-submission-store", () => ({ - useWorkspaceDraftSubmissionStore: { - getState: () => ({ - setPending: queueDraftSubmissionMock, - }), - }, -})); - -vi.mock("@/contexts/toast-context", () => ({ - useToast: () => ({ error: vi.fn() }), -})); - -vi.mock("@/hooks/use-agent-input-draft", () => ({ - useAgentInputDraft: () => { - const [attachments, setAttachments] = React.useState(initialAttachments); - const [text, setText] = React.useState(initialDraftState.text); - return { - text, - setText, - attachments, - setAttachments, - cwd: "/repo", - composerState: { - selectedProvider: "claude-code", - selectedMode: "", - modeOptions: [], - effectiveModelId: null, - effectiveThinkingOptionId: null, - commandDraftConfig: undefined, - statusControls: {}, - }, - }; - }, -})); - -vi.mock("@/hooks/use-keyboard-shift-style", () => ({ - useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }), -})); - -interface ComposerMockProps { - onSubmitMessage: (payload: { - text: string; - attachments: ComposerAttachment[]; - cwd: string; - }) => void; - submitBehavior?: "clear" | "preserve-and-lock"; - submitIcon?: "arrow" | "return"; - isSubmitLoading?: boolean; - value: string; - onChangeText: (text: string) => void; - attachments: ComposerAttachment[]; - onChangeAttachments: (attachments: ComposerAttachment[]) => void; -} - -interface AttachmentPillProps { - attachment: Extract; - attachments: ComposerAttachment[]; - isDisabled: boolean; - onChangeAttachments: (attachments: ComposerAttachment[]) => void; -} - -function ComposerMockAttachmentPill({ - attachment, - attachments, - isDisabled, - onChangeAttachments, -}: AttachmentPillProps) { - const handleRemove = React.useCallback(() => { - onChangeAttachments( - attachments.filter( - (candidate) => - candidate.kind !== attachment.kind || candidate.item.number !== attachment.item.number, - ), - ); - }, [attachment, attachments, onChangeAttachments]); - return ( -
- #{attachment.item.number} {attachment.item.title} - -
- ); -} - -function ComposerMock({ - onSubmitMessage, - submitBehavior, - submitIcon, - isSubmitLoading, - value, - onChangeText, - attachments, - onChangeAttachments, -}: ComposerMockProps) { - const isDisabled = submitBehavior === "preserve-and-lock" && Boolean(isSubmitLoading); - const handleTextareaChange = React.useCallback( - (event: React.ChangeEvent) => onChangeText(event.currentTarget.value), - [onChangeText], - ); - const handleSubmit = React.useCallback( - () => onSubmitMessage({ text: value, attachments, cwd: "/repo" }), - [onSubmitMessage, value, attachments], - ); - return ( -
-