feat: support creating worktrees from GitHub pull requests

This commit is contained in:
Mohamed Boudra
2026-04-18 12:08:30 +07:00
parent 82f97e942d
commit 14cc1cc83c
48 changed files with 6448 additions and 1515 deletions
@@ -59,6 +59,51 @@ describe("submitAgentInput", () => {
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("preserves the composer before an in-flight submit resolves when requested", async () => {
const deferred = createDeferredPromise<void>();
const attachments = [{ id: "img-1" }];
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
await deferred.promise;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " keep me ",
attachments,
submitBehavior: "preserve-and-lock",
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
});
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "keep me",
attachments,
});
expect(setUserInput).not.toHaveBeenCalled();
expect(setAttachments).not.toHaveBeenCalled();
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
deferred.resolve();
await expect(submitPromise).resolves.toBe("submitted");
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("queues while the agent is running and clears the composer immediately", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn();
@@ -5,6 +5,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
attachments: TAttachment[];
hasExternalContent?: boolean;
allowEmptySubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
forceSend?: boolean;
isAgentRunning: boolean;
canSubmit: boolean;
@@ -23,6 +24,7 @@ export async function submitAgentInput<TAttachment>(
): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim();
const attachments = input.attachments;
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
if (
!trimmedMessage &&
@@ -39,14 +41,18 @@ export async function submitAgentInput<TAttachment>(
if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, attachments });
input.setUserInput("");
input.setAttachments([]);
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);
}
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput("");
input.setAttachments([]);
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);
}
input.setSendError(null);
input.setIsProcessing(true);
@@ -57,8 +63,10 @@ export async function submitAgentInput<TAttachment>(
return "submitted";
} catch (error) {
input.onSubmitError?.(error);
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
if (shouldClearOnSubmit) {
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
}
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false);
return "failed";
@@ -10,6 +10,7 @@ interface AttachmentPillProps {
onRemove: () => void;
openAccessibilityLabel: string;
removeAccessibilityLabel: string;
disabled?: boolean;
testID?: string;
children: ReactNode;
}
@@ -19,6 +20,7 @@ export function AttachmentPill({
onRemove,
openAccessibilityLabel,
removeAccessibilityLabel,
disabled = false,
testID,
children,
}: AttachmentPillProps) {
@@ -33,6 +35,7 @@ export function AttachmentPill({
<Pressable
testID={testID}
onPress={onOpen}
disabled={disabled}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
accessibilityRole="button"
@@ -43,6 +46,7 @@ export function AttachmentPill({
</Pressable>
<Pressable
onPress={onRemove}
disabled={disabled}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
hitSlop={8}
+39 -2
View File
@@ -176,7 +176,7 @@ vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"),
Square: createIcon("Square"),
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
@@ -579,9 +579,13 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({
initialText = "",
initialAttachments = [],
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
@@ -603,6 +607,8 @@ function ComposerHarness({
return next;
});
}}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
cwd="/repo"
clearDraft={vi.fn()}
/>
@@ -611,7 +617,12 @@ function ComposerHarness({
}
function renderComposer(
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
) {
act(() => {
root?.render(<ComposerHarness {...input} />);
@@ -835,6 +846,32 @@ describe("Composer attachments", () => {
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("locks the preserved draft while submit loading", () => {
renderComposer({
initialText: "keep this prompt",
initialAttachments: [{ kind: "github_pr", item: prItem }],
isSubmitLoading: true,
submitBehavior: "preserve-and-lock",
});
const textInput = document.querySelector('[aria-label="Message agent..."]');
const attachButton = queryByTestId("message-input-attach-button");
const pill = queryByTestId("composer-github-attachment-pill");
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(textInput).toHaveProperty("readOnly", true);
expect(textInput).toHaveProperty("value", "keep this prompt");
expect(attachButton).toHaveProperty("disabled", true);
expect(pill).not.toBeNull();
expect(removeButton).not.toBeNull();
click(pill!);
click(removeButton!);
expect(openExternalUrlMock).not.toHaveBeenCalled();
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("closes the image lightbox when its close button is pressed", () => {
const image = imageAttachment("img-close");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
+10 -1
View File
@@ -102,6 +102,7 @@ interface ComposerProps {
submitButtonAccessibilityLabel?: string;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
value: string;
@@ -142,6 +143,7 @@ export function Composer({
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
isSubmitLoading = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
onChangeText,
@@ -218,6 +220,7 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading;
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
@@ -401,6 +404,7 @@ export function Composer({
hasExternalContent,
allowEmptySubmit,
forceSend,
submitBehavior,
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
@@ -815,7 +819,7 @@ export function Composer({
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */}
<View style={styles.inputAreaContainer}>
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
<View style={styles.inputAreaContent}>
{/* Queue list */}
{queuedMessages.length > 0 && (
@@ -874,6 +878,7 @@ export function Composer({
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
disabled={isComposerLocked}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
@@ -890,6 +895,7 @@ export function Composer({
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
disabled={isComposerLocked}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
@@ -1043,6 +1049,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
overflow: "visible",
padding: theme.spacing[4],
},
inputAreaLocked: {
opacity: 0.6,
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
@@ -51,7 +51,7 @@ vi.mock("lucide-react-native", () => {
return {
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"),
Plus: createIcon("Plus"),
Square: createIcon("Square"),
};
@@ -20,7 +20,7 @@ import {
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native";
import { Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
@@ -1181,7 +1181,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={buttonIconSize} color="white" />
<CornerDownLeft size={buttonIconSize} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -0,0 +1,116 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Tooltip, TooltipTrigger } from "./tooltip";
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@gorhom/bottom-sheet", () => ({
useBottomSheetModalInternal: () => null,
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
FadeIn: {},
FadeOut: {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) => styles,
},
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderTrigger({
childDisabled,
onPress,
}: {
childDisabled: boolean;
onPress: () => void;
}): void {
act(() => {
root?.render(
<Tooltip>
<TooltipTrigger asChild>
<Pressable disabled={childDisabled} onPress={onPress} testID="trigger">
<Text>Send</Text>
</Pressable>
</TooltipTrigger>
</Tooltip>,
);
});
}
function pressTrigger(): void {
const trigger = container?.querySelector('[data-testid="trigger"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
describe("TooltipTrigger", () => {
it("keeps an asChild trigger disabled when the child is disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: true, onPress });
pressTrigger();
expect(onPress).not.toHaveBeenCalled();
});
it("keeps an asChild trigger interactive when the child is not disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: false, onPress });
pressTrigger();
expect(onPress).toHaveBeenCalledTimes(1);
});
});
@@ -386,6 +386,7 @@ export function TooltipTrigger({
const mergedProps = {
...childProps,
...triggerProps,
disabled: childProps.disabled || disabled,
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { GitHubSearchItem } from "@server/shared/messages";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
const prItem: GitHubSearchItem = {
kind: "pr",
number: 42,
title: "Add picker",
url: "https://example.com/pull/42",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/picker",
};
describe("pickerItemToCheckoutRequest", () => {
it("returns undefined for no selection (null)", () => {
expect(pickerItemToCheckoutRequest(null)).toBeUndefined();
});
it("maps a branch row to branch-off with the branch name", () => {
const item: PickerItem = { kind: "branch", name: "dev" };
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "branch-off",
refName: "dev",
});
});
it("maps a github-pr row to checkout using the head ref and pr number", () => {
const item: PickerItem = {
kind: "github-pr",
item: prItem,
};
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "feature/picker",
githubPrNumber: 42,
});
});
it("handles a github-pr with a null baseRef", () => {
const item: PickerItem = {
kind: "github-pr",
item: {
...prItem,
number: 7,
title: "Orphan branch",
baseRefName: null,
headRefName: "orphan",
},
};
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "orphan",
githubPrNumber: 7,
});
});
});
@@ -0,0 +1,30 @@
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
import type { GitHubSearchItem } from "@server/shared/messages";
export type PickerItem =
| { kind: "branch"; name: string }
| {
kind: "github-pr";
item: GitHubSearchItem;
};
export type PickerCheckoutRequest = Pick<
CreatePaseoWorktreeInput,
"action" | "refName" | "githubPrNumber"
>;
export function pickerItemToCheckoutRequest(
item: PickerItem | null,
): PickerCheckoutRequest | undefined {
if (!item) return undefined;
switch (item.kind) {
case "branch":
return { action: "branch-off", refName: item.name };
case "github-pr":
return {
action: "checkout",
refName: item.item.headRefName ?? "",
githubPrNumber: item.item.number,
};
}
}
@@ -0,0 +1,781 @@
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,
setAgentsMock,
navigateMock,
createdAgent,
createdWorkspace,
prItem,
prItemB,
issueItem,
initialAttachments,
initialDraftState,
} = vi.hoisted(() => {
const theme = {
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 prItem: 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 prItemB: 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 issueItem: GitHubSearchItem = {
kind: "issue",
number: 44,
title: "Keep manual attachment",
url: "https://example.com/issues/44",
state: "open",
body: null,
labels: [],
};
const initialAttachments: ComposerAttachment[] = [];
const initialDraftState = { text: "" };
const createdWorkspace = {
id: "workspace-1",
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
};
const createdAgent = {
id: "agent-1",
cwd: createdWorkspace.workspaceDirectory,
};
const mockClient = {
isConnected: true,
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
searchGitHub: vi.fn(async () => ({
items: [prItem, prItemB],
githubFeaturesEnabled: true,
error: null,
})),
createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({
workspace: createdWorkspace,
error: null,
})),
createAgent: vi.fn(async () => createdAgent),
};
return {
theme,
mockClient,
mergeWorkspacesMock: vi.fn(),
setAgentsMock: vi.fn(),
navigateMock: vi.fn(),
createdAgent,
createdWorkspace,
prItem,
prItemB,
issueItem,
initialAttachments,
initialDraftState,
};
});
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("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
GitBranch: createIcon("GitBranch"),
GitPullRequest: createIcon("GitPullRequest"),
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
mergeWorkspaces: mergeWorkspacesMock,
setAgents: setAgentsMock,
}),
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("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/hooks/use-agent-input-draft", () => ({
useAgentInputDraft: () => {
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>(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("@/components/composer", () => ({
Composer: ({
onSubmitMessage,
submitBehavior,
isSubmitLoading,
value,
onChangeText,
attachments,
onChangeAttachments,
}: {
onSubmitMessage: (payload: {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}) => void;
submitBehavior?: "clear" | "preserve-and-lock";
isSubmitLoading?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}) => (
<div data-testid="test-composer" data-submit-behavior={submitBehavior}>
<textarea
aria-label="Message agent..."
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
value={value}
onChange={(event) => onChangeText(event.currentTarget.value)}
/>
<button
type="button"
data-testid="message-input-attach-button"
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
>
Attach
</button>
{attachments.map((attachment) =>
attachment.kind === "github_pr" || attachment.kind === "github_issue" ? (
<div
data-testid="composer-github-attachment-pill"
key={`${attachment.kind}-${attachment.item.number}`}
>
#{attachment.item.number} {attachment.item.title}
<button
type="button"
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${
attachment.item.number
}`}
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
onClick={() =>
onChangeAttachments(
attachments.filter(
(candidate) =>
candidate.kind !== attachment.kind ||
candidate.item.number !== attachment.item.number,
),
)
}
>
Remove
</button>
</div>
) : null,
)}
<button
type="button"
data-testid="test-composer-submit"
onClick={() => onSubmitMessage({ text: value, attachments, cwd: "/repo" })}
>
Submit
</button>
</div>
),
}));
vi.mock("@/components/composer-attachments", () => ({
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
images: [],
attachments: attachments.flatMap((attachment) => {
if (attachment.kind === "github_pr") {
return [
{
type: "github_pr",
mimeType: "application/github-pr",
number: attachment.item.number,
title: attachment.item.title,
url: attachment.item.url,
},
];
}
if (attachment.kind === "github_issue") {
return [
{
type: "github_issue",
mimeType: "application/github-issue",
number: attachment.item.number,
title: attachment.item.title,
url: attachment.item.url,
},
];
}
return [];
}),
}),
}));
vi.mock("@/components/desktop/titlebar-drag-region", () => ({
TitlebarDragRegion: () => null,
}));
vi.mock("@/components/headers/menu-header", () => ({
SidebarMenuToggle: () => null,
}));
vi.mock("@/components/headers/screen-header", () => ({
ScreenHeader: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
open,
options,
renderOption,
onSelect,
}: {
open?: boolean;
options: Array<{ id: string; label: string }>;
renderOption?: (input: {
option: { id: string; label: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
onSelect: (id: string) => void;
}) => {
if (!open) return null;
return (
<div data-testid="ref-picker-combobox">
{options.map((option) =>
renderOption ? (
<React.Fragment key={option.id}>
{renderOption({
option,
selected: false,
active: false,
onPress: () => onSelect(option.id),
})}
</React.Fragment>
) : (
<button type="button" key={option.id} onClick={() => onSelect(option.id)}>
{option.label}
</button>
),
)}
</div>
);
},
ComboboxItem: ({
label,
onPress,
testID,
disabled,
}: {
label: string;
onPress: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onPress}>
{label}
</button>
),
}));
vi.mock("mnemonic-id", () => ({
createNameId: () => "gentle-slug",
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
return actual;
});
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockClient.getBranchSuggestions.mockClear();
mockClient.searchGitHub.mockClear();
mockClient.searchGitHub.mockResolvedValue({
items: [prItem, prItemB],
githubFeaturesEnabled: true,
error: null,
});
mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear();
initialAttachments.length = 0;
initialDraftState.text = "";
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container = null;
queryClient = null;
vi.unstubAllGlobals();
});
function renderScreen() {
act(() => {
root?.render(
<QueryClientProvider client={queryClient!}>
<NewWorkspaceScreen serverId="server" sourceDirectory="/repo" />
</QueryClientProvider>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function findByTestId(testID: string): Promise<HTMLElement> {
for (let attempt = 0; attempt < 10; attempt += 1) {
await flush();
const element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
if (element) return element;
}
throw new Error(`Missing element with testID ${testID}`);
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByTestId(testID: string): HTMLElement[] {
return Array.from(document.querySelectorAll(`[data-testid="${testID}"]`)) as HTMLElement[];
}
type CreatePaseoWorktreeArg = Parameters<typeof mockClient.createPaseoWorktree>[0];
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
const calls = mockClient.createPaseoWorktree.mock.calls;
const firstCall = calls[0];
if (!firstCall) throw new Error("createPaseoWorktree not called");
return firstCall[0];
}
describe("NewWorkspaceScreen picker payload", () => {
it("searches only GitHub PRs for the picker", async () => {
renderScreen();
await flush();
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo",
query: "",
limit: 20,
kinds: ["github-pr"],
});
});
it("sends no new fields when nothing is selected (default)", async () => {
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
});
expect(call).not.toHaveProperty("refName");
expect(call).not.toHaveProperty("action");
expect(call).not.toHaveProperty("githubPrNumber");
});
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
const trigger = await findByTestId("new-workspace-ref-picker-trigger");
expect(trigger.textContent).toContain(`#${prItem.number}`);
expect(trigger.textContent).toContain(prItem.title);
expect(trigger.textContent).not.toContain("feature/picker");
expect(trigger.querySelectorAll('[data-icon="GitPullRequest"]')).toHaveLength(1);
});
it("adds the selected picker PR as a composer attachment pill", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${prItem.number}`);
expect(pills[0]?.textContent).toContain("Refactor picker");
});
it("replaces the picker-owned composer PR pill when another PR is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItemB.number}`));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${prItemB.number}`);
expect(pills[0]?.textContent).toContain("Polish composer chip");
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
});
it("removes the picker-owned PR pill when switching to a branch and preserves user-added chips", async () => {
initialAttachments.push({ kind: "github_issue", item: issueItem });
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${issueItem.number}`);
expect(pills[0]?.textContent).toContain(issueItem.title);
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
});
it("sends action=branch-off with the branch name when a branch row is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "branch-off",
refName: "dev",
});
expect(call).not.toHaveProperty("githubPrNumber");
});
it("sends action=checkout with pr number + head ref when a github PR row is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "checkout",
refName: "feature/picker",
githubPrNumber: 202,
});
});
it("sends the selected PR attachment when creating the worktree", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const call = firstCreateWorktreeCall();
const attachments = call.attachments ?? [];
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 });
});
it("keeps the selected PR attachment when the PR query refreshes after selection", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
mockClient.searchGitHub.mockResolvedValue({
items: [],
githubFeaturesEnabled: true,
error: null,
});
await act(async () => {
await queryClient?.invalidateQueries({
queryKey: ["new-workspace-github-prs", "server", "/repo"],
});
});
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const call = firstCreateWorktreeCall();
expect(call.attachments).toHaveLength(1);
expect(call.attachments?.[0]).toMatchObject({ type: "github_pr", number: 202 });
});
it("omits PR rows from the picker when GitHub features are disabled", async () => {
mockClient.searchGitHub.mockResolvedValueOnce({
items: [],
githubFeaturesEnabled: false,
error: null,
});
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
expect(queryByTestId(`new-workspace-ref-picker-pr-${prItem.number}`)).toBeNull();
expect((await findByTestId("new-workspace-ref-picker-branch-dev")).textContent).toContain(
"dev",
);
});
it("preserves and locks the composer and picker while chat creation is pending, then unlocks on error", async () => {
initialDraftState.text = "please review this change";
const createAgent = createDeferredPromise<typeof createdAgent>();
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const textInput = document.querySelector('[aria-label="Message agent..."]');
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock");
expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", true);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(removeButton).toHaveProperty("disabled", true);
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
createAgent.reject(new Error("Create agent failed"));
await flush();
expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", false);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(removeButton).toHaveProperty("disabled", false);
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", false);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", false);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", false);
});
});
+276 -110
View File
@@ -3,13 +3,11 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SegmentedControlOption } from "@/components/ui/segmented-control";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
@@ -29,8 +27,10 @@ import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
import type { AgentAttachment } from "@server/shared/messages";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
interface NewWorkspaceScreenProps {
serverId: string;
@@ -38,24 +38,69 @@ interface NewWorkspaceScreenProps {
displayName?: string;
}
type BaseRef = { name: string };
interface Checkout {
action: "checkout" | "branch-off";
ref: BaseRef;
interface PickerOptionData {
options: ComboboxOptionType[];
itemById: Map<string, PickerItem>;
firstBranchId: string | null;
firstPrId: string | null;
}
function refId(ref: BaseRef): string {
return `branch:${ref.name}`;
interface PickerSelection {
item: PickerItem;
attachedPrNumber: number | null;
}
function refLabel(ref: BaseRef): string {
return ref.name;
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
function branchOptionId(name: string): string {
return `${BRANCH_OPTION_PREFIX}${name}`;
}
function formatCheckoutBadge(c: Checkout): string {
const label = c.ref.name;
return c.action === "branch-off" ? `new branch off ${label}` : label;
function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`;
}
function pickerItemLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function syncPickerPrAttachment(input: {
attachments: ComposerAttachment[];
previousPickerPrNumber: number | null;
item: PickerItem;
}): { attachments: ComposerAttachment[]; 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 };
}
export function NewWorkspaceScreen({
@@ -72,13 +117,14 @@ export function NewWorkspaceScreen({
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const [checkout, setCheckout] = useState<Checkout | null>(null);
const [checkoutPickerOpen, setCheckoutPickerOpen] = useState(false);
const [pickerAction, setPickerAction] = useState<Checkout["action"]>("branch-off");
const checkoutAnchorRef = useRef<View>(null);
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerAnchorRef = useRef<View>(null);
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace;
const selectedItem = pickerSelection?.item ?? null;
const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
@@ -121,41 +167,115 @@ export function NewWorkspaceScreen({
return connectedClient.getBranchSuggestions({ cwd: sourceDirectory, limit: 20 });
},
enabled: isConnected && !!client,
staleTime: 15_000,
});
const checkoutRefs: BaseRef[] = useMemo(
() => (branchSuggestionsQuery.data?.branches ?? []).map((name): BaseRef => ({ name })),
[branchSuggestionsQuery.data?.branches],
const githubPrSearchQuery = useQuery({
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory],
queryFn: async () => {
const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({
cwd: sourceDirectory,
query: "",
limit: 20,
kinds: ["github-pr"],
});
},
enabled: isConnected && !!client,
staleTime: 60_000,
});
const branchNames: string[] = branchSuggestionsQuery.data?.branches ?? [];
const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
const prItems: GitHubSearchItem[] = useMemo(() => {
if (!githubFeaturesEnabled) return [];
const items = githubPrSearchQuery.data?.items ?? [];
return items.filter((item): item is GitHubSearchItem => item.kind === "pr");
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
const { options, itemById, firstBranchId, firstPrId }: PickerOptionData = useMemo(() => {
const collectedOptions: ComboboxOptionType[] = [];
const idMap = new Map<string, PickerItem>();
let firstBranch: string | null = null;
let firstPr: string | null = null;
for (const name of branchNames) {
const id = branchOptionId(name);
collectedOptions.push({ id, label: name });
idMap.set(id, { kind: "branch", name });
if (firstBranch === null) firstBranch = id;
}
for (const pr of prItems) {
if (!pr.headRefName) continue;
const id = prOptionId(pr.number);
collectedOptions.push({ id, label: formatPrLabel(pr) });
idMap.set(id, {
kind: "github-pr",
item: pr,
});
if (firstPr === null) firstPr = id;
}
return {
options: collectedOptions,
itemById: idMap,
firstBranchId: firstBranch,
firstPrId: firstPr,
};
}, [branchNames, prItems]);
const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
return currentBranch ?? "main";
}, [currentBranch, selectedItem]);
const selectedOptionId = useMemo(() => {
if (!selectedItem) return "";
return selectedItem.kind === "branch"
? branchOptionId(selectedItem.name)
: prOptionId(selectedItem.item.number);
}, [selectedItem]);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
const next = syncPickerPrAttachment({
attachments: chatDraft.attachments,
previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null,
item,
});
setPickerSelection({
item,
attachedPrNumber: next.attachedPrNumber,
});
if (next.attachments !== chatDraft.attachments) {
chatDraft.setAttachments(next.attachments);
}
setPickerOpen(false);
},
[chatDraft, itemById, pickerSelection?.attachedPrNumber],
);
const checkoutOptions: ComboboxOptionType[] = useMemo(
() =>
checkoutRefs.map((ref) => ({
id: refId(ref),
label: refLabel(ref),
})),
[checkoutRefs],
);
const effectiveCheckout: Checkout | null =
checkout ?? (currentBranch ? { action: "branch-off", ref: { name: currentBranch } } : null);
const commitCheckout = useCallback((ref: BaseRef, action: Checkout["action"]) => {
setCheckout({ action, ref });
setCheckoutPickerOpen(false);
const openPicker = useCallback(() => {
setPickerOpen(true);
}, []);
const openCheckoutPicker = useCallback(() => {
setPickerAction(effectiveCheckout?.action ?? "branch-off");
setCheckoutPickerOpen(true);
}, [effectiveCheckout]);
const buildCreateWorktreeInput = useCallback(
(input: { cwd: string; attachments: AgentAttachment[] }) => {
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
const checkoutModeOptions: SegmentedControlOption<Checkout["action"]>[] = useMemo(
() => [
{ value: "checkout", label: "Check out" },
{ value: "branch-off", label: "Branch off" },
],
[],
return {
cwd: input.cwd,
worktreeSlug: createNameId(),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
...(checkoutRequest ?? {}),
};
},
[selectedItem],
);
const ensureWorkspace = useCallback(
@@ -165,11 +285,7 @@ export function NewWorkspaceScreen({
}
const connectedClient = withConnectedClient();
const payload = await connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
const payload = await connectedClient.createPaseoWorktree(buildCreateWorktreeInput(input));
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
@@ -180,7 +296,7 @@ export function NewWorkspaceScreen({
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
},
[createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
);
const handleCreateChatAgent = useCallback(
@@ -251,6 +367,72 @@ export function NewWorkspaceScreen({
addImagesRef.current = addImages;
}, []);
const renderPickerOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOptionType;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const item = itemById.get(option.id);
if (!item) return <View key={option.id} />;
const isBranch = item.kind === "branch";
const isFirstInSection =
(isBranch && option.id === firstBranchId) || (!isBranch && option.id === firstPrId);
const header = isFirstInSection ? (
<Text style={styles.sectionHeader}>{isBranch ? "Branches" : "Pull requests"}</Text>
) : null;
const leadingSlot = (
<View style={styles.rowIconBox}>
{isBranch ? (
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
);
const testID = isBranch
? `new-workspace-ref-picker-branch-${item.name}`
: `new-workspace-ref-picker-pr-${item.item.number}`;
const description =
!isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined;
return (
<>
{header}
<ComboboxItem
testID={testID}
label={pickerItemLabel(item)}
description={description}
selected={selected}
active={active}
disabled={isPending}
onPress={onPress}
leadingSlot={leadingSlot}
/>
</>
);
},
[
firstBranchId,
firstPrId,
isPending,
itemById,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
);
return (
<View style={styles.container}>
<ScreenHeader
@@ -281,6 +463,7 @@ export function NewWorkspaceScreen({
allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create"
isSubmitLoading={pendingAction === "chat"}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
@@ -296,7 +479,7 @@ export function NewWorkspaceScreen({
composerState
? {
...composerState.statusControls,
disabled: pendingAction !== null,
disabled: isPending,
}
: undefined
}
@@ -307,21 +490,31 @@ export function NewWorkspaceScreen({
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={checkoutAnchorRef}
onPress={openCheckoutPicker}
ref={pickerAnchorRef}
testID="new-workspace-ref-picker-trigger"
onPress={openPicker}
disabled={isPending}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
hovered && !isPending && styles.badgeHovered,
pressed && !isPending && styles.badgePressed,
isPending && styles.badgeDisabled,
]}
accessibilityRole="button"
accessibilityLabel="Checkout ref"
accessibilityLabel="Starting ref"
>
<View style={styles.badgeIconBox}>
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{selectedItem?.kind === "github-pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{effectiveCheckout ? formatCheckoutBadge(effectiveCheckout) : "main"}
{triggerLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -331,51 +524,18 @@ export function NewWorkspaceScreen({
</TooltipContent>
</Tooltip>
<Combobox
options={checkoutOptions}
value={effectiveCheckout ? refId(effectiveCheckout.ref) : ""}
onSelect={(id) => {
const ref = checkoutRefs.find((r) => refId(r) === id);
if (ref) commitCheckout(ref, pickerAction);
}}
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder="Search branches"
searchPlaceholder="Search branches and PRs"
title="Start from"
open={checkoutPickerOpen}
onOpenChange={setCheckoutPickerOpen}
open={pickerOpen}
onOpenChange={setPickerOpen}
desktopPlacement="bottom-start"
anchorRef={checkoutAnchorRef}
emptyText="No branches."
stickyHeader={
<View style={styles.checkoutPickerHeader}>
<SegmentedControl
size="sm"
options={checkoutModeOptions}
value={pickerAction}
onValueChange={setPickerAction}
/>
</View>
}
renderOption={({ option, selected, active, onPress }) => {
const ref = checkoutRefs.find((r) => refId(r) === option.id);
if (!ref) return <View key={option.id} />;
return (
<ComboboxItem
key={option.id}
label={refLabel(ref)}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
<View style={styles.rowIconBox}>
<GitBranch
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</View>
}
/>
);
}}
anchorRef={pickerAnchorRef}
emptyText="No matching refs."
renderOption={renderPickerOption}
/>
</View>
</View>
@@ -458,6 +618,9 @@ const styles = StyleSheet.create((theme) => ({
badgePressed: {
backgroundColor: theme.colors.surface0,
},
badgeDisabled: {
opacity: 0.6,
},
badgeText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
@@ -467,12 +630,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
checkoutPickerHeader: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
alignItems: "flex-start",
},
badgeIconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
@@ -486,4 +643,13 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0,
},
}));