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 (