feat: support creating worktrees from GitHub pull requests
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 }] });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user