Auto-attach pasted GitHub PR/issue URLs in the composer (#1006)

* Auto-attach pasted GitHub PR/issue URLs in the composer

Paste a GitHub PR or issue link into any composer and it shows up
as an attachment pill within a debounce tick when the URL matches
the workspace's remote. On the new-workspace screen, an attached PR
also surfaces an inline "Check out PR #N?" hint next to the picker;
one click promotes it to the checkout target through the same
syncPickerPrAttachment flow the dropdown uses.

Unifies the new-workspace picker and composer attachment picker on
one shared GitHub search hook and cache key, so both consumers (and
the auto-attach lookup) share the React Query cache.

* Fix oxfmt formatting in new-workspace-screen import
This commit is contained in:
Mohamed Boudra
2026-05-14 17:52:46 +08:00
committed by GitHub
parent 8296c797ff
commit 2e8bced707
12 changed files with 1147 additions and 130 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
@@ -19,6 +19,7 @@ import {
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter,
type AttachmentPersister,
type ComposerCancelClient,
@@ -696,6 +697,36 @@ describe("toggleGithubAttachment", () => {
});
});
describe("toggleGithubAttachmentFromPicker", () => {
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
const markGithubAttachmentRemoved = vi.fn();
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
const next = toggleGithubAttachmentFromPicker({
current: [attachment],
item: prItem,
markGithubAttachmentRemoved,
});
expect(next).toEqual([]);
expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1);
expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment);
});
it("does not mark a GitHub item removed when picker toggle adds it", () => {
const markGithubAttachmentRemoved = vi.fn();
const next = toggleGithubAttachmentFromPicker({
current: [],
item: issueItem,
markGithubAttachmentRemoved,
});
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
});
});
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
@@ -305,6 +305,29 @@ export function toggleGithubAttachment(
return [...current, buildGithubAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
export function toggleGithubAttachmentFromPicker({
current,
item,
markGithubAttachmentRemoved,
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
optionId: string,
+51 -40
View File
@@ -21,7 +21,6 @@ import {
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import {
AgentStatusBar,
@@ -53,7 +52,7 @@ import {
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
@@ -97,6 +96,9 @@ import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
type QueuedMessage = QueuedComposerMessage;
@@ -137,6 +139,20 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
function resolveGithubSearchEnabled(
isGithubPickerOpen: boolean,
isConnected: boolean,
cwd: string,
): boolean {
return isGithubPickerOpen && isConnected && cwd.trim().length > 0;
}
function resolveCheckoutRemoteUrl(
checkoutStatus: ReturnType<typeof useCheckoutStatusQuery>["status"],
): string | null {
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
@@ -164,31 +180,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
};
}
interface BuildGithubSearchQueryOptionsArgs {
serverId: string;
cwd: string;
githubSearchQueryTrimmed: string;
isGithubPickerOpen: boolean;
isConnected: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
}
function buildGithubSearchQueryOptions(args: BuildGithubSearchQueryOptionsArgs) {
const { serverId, cwd, githubSearchQueryTrimmed, isGithubPickerOpen, isConnected, client } = args;
const hasClient = Boolean(client);
const cwdIsSet = cwd.trim().length > 0;
const enabled = isGithubPickerOpen && isConnected && hasClient && cwdIsSet;
return {
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
queryFn: async () => {
if (!client) throw new Error("Host is not connected");
return client.searchGitHub({ cwd, query: githubSearchQueryTrimmed, limit: 20 });
},
enabled,
staleTime: 30_000,
};
}
function renderContextWindowMeterSlot(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
@@ -890,6 +881,17 @@ export function Composer({
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client,
isConnected,
serverId,
cwd,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
@@ -1135,6 +1137,7 @@ export function Composer({
const handleRemoveAttachment = useCallback(
(index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
@@ -1146,7 +1149,7 @@ export function Composer({
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
@@ -1376,16 +1379,13 @@ export function Composer({
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useQuery(
buildGithubSearchQueryOptions({
serverId,
cwd,
githubSearchQueryTrimmed,
isGithubPickerOpen,
isConnected,
client,
}),
);
const githubSearchResultsQuery = useGithubSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
const githubSearchItemsRaw = githubSearchResultsQuery.data?.items;
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
@@ -1423,11 +1423,22 @@ export function Composer({
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
setSelectedAttachments((current) => toggleGithubAttachment(current, item));
const nextAttachments = toggleGithubAttachmentFromPicker({
current: attachments,
item,
markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved,
});
setSelectedAttachments(nextAttachments);
setIsGithubPickerOpen(false);
setGithubSearchQuery("");
},
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
[
attachments,
githubAutoAttach,
setSelectedAttachments,
setGithubSearchQuery,
setIsGithubPickerOpen,
],
);
const leftContent = useMemo(
@@ -0,0 +1,213 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: GitHubSearchItem = {
kind: "pr",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature",
};
const issue202: GitHubSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
url: "https://github.com/acme/paseo/issues/202",
state: "open",
body: null,
labels: [],
baseRefName: null,
headRefName: null,
};
interface SearchCall {
cwd: string;
query: string;
limit?: number;
}
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialText?: string;
remote?: string | null;
}
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
return {
items,
githubFeaturesEnabled: true,
error: null,
requestId,
};
}
function createSearchClient(
items: GitHubSearchItem[],
): GitHubSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchGitHub(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
};
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
);
const autoAttach = useComposerGithubAutoAttach({
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client,
isConnected: true,
serverId: "server-1",
cwd,
setAttachments,
});
return {
text,
setText,
attachments,
setAttachments,
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
};
}
async function flushDebounce() {
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
await Promise.resolve();
});
}
describe("useComposerGithubAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
it("ignores URLs that do not match the current remote", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Other repo https://github.com/other/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not add a second pill when the ref is already attached", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Already here https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual(initialAttachments);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not re-add a GitHub ref removed earlier in the same composer session", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.markGithubAttachmentRemoved(initialAttachments[0]);
result.current.setAttachments([]);
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("handles multiple matching URLs from one paste", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101, issue202]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202",
);
});
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "github_pr", item: pr101 },
{ kind: "github_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
]);
vi.useRealTimers();
});
});
@@ -0,0 +1,240 @@
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useCallback,
useEffect,
useMemo,
useRef,
type Dispatch,
type RefObject,
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildGithubSearchQueryOptions,
type GitHubSearchClient,
} from "@/git/use-github-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@server/shared/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: GitHubSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
}
interface ComposerGithubAutoAttachResult {
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
}
export function useComposerGithubAutoAttach(
params: ComposerGithubAutoAttachInput,
): ComposerGithubAutoAttachResult {
const queryClient = useQueryClient();
const latestRef = useRef(params);
const removedRefKeysRef = useRef(new Set<string>());
const pendingRefKeysRef = useRef(new Set<string>());
latestRef.current = params;
useEffect(() => {
const refs = refsReadyForLookup({
params: latestRef.current,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
if (refs.length === 0) {
return;
}
const timerId = setTimeout(() => {
void attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
}, AUTO_ATTACH_DEBOUNCE_MS);
return () => {
clearTimeout(timerId);
};
}, [
params.text,
params.remoteUrl,
params.attachments,
params.client,
params.isConnected,
params.serverId,
params.cwd,
queryClient,
]);
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
const key = attachmentKey(attachment);
if (key) {
removedRefKeysRef.current.add(key);
}
}, []);
return useMemo(
() => ({
markGithubAttachmentRemoved,
}),
[markGithubAttachmentRemoved],
);
}
async function attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys,
pendingRefKeys,
}: {
refs: GithubRef[];
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): Promise<void> {
for (const ref of refs) {
const key = githubRefKey(ref);
if (pendingRefKeys.has(key)) {
continue;
}
pendingRefKeys.add(key);
try {
await attachRef({ ref, key, queryClient, latestRef, removedRefKeys });
} finally {
pendingRefKeys.delete(key);
}
}
}
async function attachRef({
ref,
key,
queryClient,
latestRef,
removedRefKeys,
}: {
ref: GithubRef;
key: string;
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
}): Promise<void> {
const snapshot = latestRef.current;
if (!snapshot.client || !snapshot.isConnected || !isRefStillPresent(ref, snapshot)) {
return;
}
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
if (!search) {
return;
}
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
return;
}
latestRef.current.setAttachments((current) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
return current;
}
return toggleGithubAttachment(current, item);
});
}
function refsReadyForLookup({
params,
removedRefKeys,
pendingRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): GithubRef[] {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
return [];
}
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
const key = githubRefKey(ref);
return (
!removedRefKeys.has(key) &&
!pendingRefKeys.has(key) &&
!hasGithubAttachment(params.attachments, ref)
);
});
}
async function fetchGithubRefSearch({
ref,
snapshot,
queryClient,
}: {
ref: GithubRef;
snapshot: ComposerGithubAutoAttachInput;
queryClient: QueryClient;
}) {
if (!snapshot.client) {
return null;
}
try {
return await queryClient.fetchQuery(
buildGithubSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
enabled: true,
}),
);
} catch {
return null;
}
}
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
return extractGithubRefs(params.text, params.remoteUrl).some(
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
);
}
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
return ref.kind === "pull" ? "pr" : "issue";
}
function githubRefKey(ref: GithubRef): string {
return `${githubItemKind(ref)}:${ref.number}`;
}
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
) {
return null;
}
return `${attachment.item.kind}:${attachment.item.number}`;
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query";
describe("githubSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"github-search",
"server-1",
"/repo",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual(
["github-search", "server-1", "/repo", "123", "github-issue,github-pr"],
);
});
});
describe("buildGithubSearchQueryOptions", () => {
it("forwards kinds to the GitHub search request when specified", async () => {
const requests: unknown[] = [];
const query = buildGithubSearchQueryOptions({
client: {
async searchGitHub(options) {
requests.push(options);
return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" };
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["github-pr"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]);
});
});
@@ -0,0 +1,64 @@
import { useQuery } from "@tanstack/react-query";
import type { GitHubSearchRequest, GitHubSearchResponse } from "@server/shared/messages";
export const GITHUB_SEARCH_STALE_TIME = 30_000;
export type GitHubSearchPayload = GitHubSearchResponse["payload"];
export interface GitHubSearchClient {
searchGitHub: (
options: {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchRequest["kinds"];
},
requestId?: string,
) => Promise<GitHubSearchPayload>;
}
interface GitHubSearchQueryInput {
client: GitHubSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: GitHubSearchRequest["kinds"];
enabled: boolean;
}
export function githubSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: GitHubSearchRequest["kinds"],
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["github-search", serverId, cwd, trimmedQuery] as const;
}
return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const;
}
export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
const query = input.query.trim();
return {
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
queryFn: async (): Promise<GitHubSearchPayload> => {
if (!input.client) {
throw new Error("Host is not connected");
}
const request = { cwd: input.cwd, query, limit: 20 };
if (input.kinds) {
return input.client.searchGitHub({ ...request, kinds: input.kinds });
}
return input.client.searchGitHub(request);
},
enabled: input.enabled && Boolean(input.client),
staleTime: GITHUB_SEARCH_STALE_TIME,
};
}
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
return useQuery(buildGithubSearchQueryOptions(input));
}
@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import {
deriveAutoPickerItemFromAttachments,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return {
@@ -20,7 +17,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
};
}
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
function prAttachment(
item: GitHubSearchItem,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item };
}
@@ -98,34 +97,53 @@ describe("syncPickerPrAttachment", () => {
});
});
describe("deriveAutoPickerItemFromAttachments", () => {
it("returns null when there are no attachments", () => {
expect(deriveAutoPickerItemFromAttachments([])).toBeNull();
describe("findCheckoutHintPrAttachment", () => {
it("returns the first attached PR that is not selected or dismissed", () => {
const first = prAttachment(makePrItem(101, "A"));
const second = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), first, second],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBe(first);
});
it("returns the PR when exactly one is attached", () => {
const pr = makePrItem(923, "Nix overridable npm deps hash");
expect(deriveAutoPickerItemFromAttachments([prAttachment(pr)])).toEqual({
kind: "github-pr",
item: pr,
});
it("skips the selected PR and offers the next attached PR", () => {
const selected = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [selected, next],
selectedItem: { kind: "github-pr", item: selected.item },
dismissedPrNumbers: new Set(),
}),
).toBe(next);
});
it("returns null when multiple PRs are attached", () => {
const a = makePrItem(101, "A");
const b = makePrItem(202, "B");
expect(deriveAutoPickerItemFromAttachments([prAttachment(a), prAttachment(b)])).toBeNull();
it("skips dismissed PRs and ignores issues", () => {
const dismissed = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), dismissed, next],
selectedItem: null,
dismissedPrNumbers: new Set([101]),
}),
).toBe(next);
});
it("ignores non-PR attachments", () => {
expect(deriveAutoPickerItemFromAttachments([issueAttachment(44)])).toBeNull();
});
it("returns the lone PR even when other non-PR attachments are present", () => {
const pr = makePrItem(923, "Nix overridable npm deps hash");
expect(deriveAutoPickerItemFromAttachments([issueAttachment(44), prAttachment(pr)])).toEqual({
kind: "github-pr",
item: pr,
});
it("returns null when only issues qualify", () => {
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44)],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBeNull();
});
});
@@ -34,18 +34,21 @@ export function syncPickerPrAttachment(input: {
return { attachments: nextAttachments, attachedPrNumber };
}
// Suggest a ref picker target derived from composer attachments alone. When the
// user attaches exactly one PR they almost always want the worktree based on
// that PR — surface it as the picker's default so the worktree isn't silently
// branched off main. The caller is responsible for letting manual picks win.
export function deriveAutoPickerItemFromAttachments(
attachments: ReadonlyArray<UserComposerAttachment>,
): PickerItem | null {
let onlyPr: Extract<UserComposerAttachment, { kind: "github_pr" }> | null = null;
for (const attachment of attachments) {
export function findCheckoutHintPrAttachment(input: {
attachments: ReadonlyArray<UserComposerAttachment>;
selectedItem: PickerItem | null;
dismissedPrNumbers: ReadonlySet<number>;
}): Extract<UserComposerAttachment, { kind: "github_pr" }> | null {
const selectedPrNumber =
input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null;
for (const attachment of input.attachments) {
if (attachment.kind !== "github_pr") continue;
if (onlyPr) return null;
onlyPr = attachment;
const prNumber = attachment.item.number;
if (prNumber === selectedPrNumber) continue;
if (input.dismissedPrNumbers.has(prNumber)) continue;
return attachment;
}
return onlyPr ? { kind: "github-pr", item: onlyPr.item } : null;
return null;
}
+174 -49
View File
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { FileDropZone } from "@/components/file-drop-zone";
@@ -19,6 +19,7 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
@@ -38,10 +39,7 @@ import {
type PickerCheckoutRequest,
type PickerItem,
} from "./new-workspace-picker-item";
import {
deriveAutoPickerItemFromAttachments,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function resolveCheckoutRequest(
selectedItem: PickerItem | null,
@@ -72,17 +70,6 @@ interface PickerSelection {
attachedPrNumber: number | null;
}
// Manual picks always win; the auto-promoted item is a fallback so the user
// doesn't silently get "main" when they meant the PR they just attached.
function combinePickerSelection(
manual: PickerSelection | null,
autoItem: PickerItem | null,
): PickerSelection | null {
if (manual) return manual;
if (autoItem) return { item: autoItem, attachedPrNumber: null };
return null;
}
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
@@ -160,6 +147,46 @@ function RefPickerTrigger({
);
}
function CheckoutHintBadge({
prNumber,
onAccept,
onDismiss,
iconColor,
iconSize,
}: {
prNumber: number;
onAccept: () => void;
onDismiss: () => void;
iconColor: string;
iconSize: number;
}) {
return (
<View style={styles.checkoutHintBadge}>
<Text style={styles.badgeText} numberOfLines={1}>
Check out PR #{prNumber}?
</Text>
<Pressable
testID="new-workspace-checkout-hint-accept"
onPress={onAccept}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={`Check out PR #${prNumber}`}
>
<Check size={iconSize} color={iconColor} />
</Pressable>
<Pressable
testID="new-workspace-checkout-hint-dismiss"
onPress={onDismiss}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={`Dismiss PR #${prNumber} checkout hint`}
>
<X size={iconSize} color={iconColor} />
</Pressable>
</View>
);
}
function PickerOptionItem({
testID,
label,
@@ -262,6 +289,17 @@ function computePickerOptionData(
return { options: timedOptions.map((t) => t.option), itemById: idMap };
}
function normalizeBranchDetails(
data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
| undefined,
): Array<{ name: string; committerDate: number }> {
const details = data?.branchDetails;
if (details && details.length > 0) return details;
const names = data?.branches ?? [];
return names.map((name) => ({ name, committerDate: 0 }));
}
interface SubmitDraftInput {
serverId: string;
draftKey: string;
@@ -363,6 +401,47 @@ function computeWorkspaceTitle(
);
}
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
const numbers = new Set<number>();
for (const attachment of attachments) {
if (attachment.kind === "github_pr") {
numbers.add(attachment.item.number);
}
}
return numbers;
}
function pruneDismissedCheckoutHintPrNumbers(
dismissed: ReadonlySet<number>,
attached: ReadonlySet<number>,
): ReadonlySet<number> {
let changed = false;
const next = new Set<number>();
for (const prNumber of dismissed) {
if (attached.has(prNumber)) {
next.add(prNumber);
} else {
changed = true;
}
}
return changed ? next : dismissed;
}
function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttachment>) {
const [dismissedPrNumbers, setDismissedPrNumbers] = useState<ReadonlySet<number>>(
() => new Set(),
);
const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]);
useEffect(() => {
setDismissedPrNumbers((current) =>
pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers),
);
}, [attachedPrNumbers]);
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
@@ -460,13 +539,10 @@ export function NewWorkspaceScreen({
}),
});
const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
useCheckoutHintDismissals(chatDraft.attachments);
const autoPickerItem = useMemo(
() => deriveAutoPickerItemFromAttachments(chatDraft.attachments),
[chatDraft.attachments],
);
const pickerSelection = combinePickerSelection(manualPickerSelection, autoPickerItem);
const selectedItem = pickerSelection?.item ?? null;
const selectedItem = manualPickerSelection?.item ?? null;
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
@@ -507,32 +583,23 @@ export function NewWorkspaceScreen({
staleTime: 15_000,
});
const githubPrSearchQuery = useQuery({
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => {
const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({
cwd: sourceDirectory,
query: debouncedPickerSearchQuery,
limit: 20,
kinds: ["github-pr"],
});
},
const githubPrSearchQuery = useGithubSearchQuery({
client,
serverId,
cwd: sourceDirectory,
query: debouncedPickerSearchQuery,
kinds: ["github-pr"],
enabled: pickerQueryEnabled,
staleTime: 30_000,
});
const branchDetails = useMemo(() => {
const details = branchSuggestionsQuery.data?.branchDetails;
if (details && details.length > 0) return details;
const names = branchSuggestionsQuery.data?.branches ?? [];
return names.map((name) => ({ name, committerDate: 0 }));
}, [branchSuggestionsQuery.data?.branchDetails, branchSuggestionsQuery.data?.branches]);
const branchDetails = useMemo(
() => normalizeBranchDetails(branchSuggestionsQuery.data),
[branchSuggestionsQuery.data],
);
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");
return githubPrSearchQuery.data?.items ?? [];
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
const { options, itemById }: PickerOptionData = useMemo(
@@ -552,14 +619,11 @@ export function NewWorkspaceScreen({
: prOptionId(selectedItem.item.number);
}, [selectedItem]);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
const selectPickerItem = useCallback(
(item: PickerItem) => {
const next = syncPickerPrAttachment({
attachments: chatDraft.attachments,
previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null,
previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null,
item,
});
@@ -572,9 +636,44 @@ export function NewWorkspaceScreen({
}
setPickerOpen(false);
},
[chatDraft, itemById, pickerSelection?.attachedPrNumber],
[chatDraft, manualPickerSelection?.attachedPrNumber],
);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
selectPickerItem(item);
},
[itemById, selectPickerItem],
);
const checkoutHintPrAttachment = useMemo(
() =>
findCheckoutHintPrAttachment({
attachments: chatDraft.attachments,
selectedItem,
dismissedPrNumbers: dismissedCheckoutHintPrNumbers,
}),
[chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem],
);
const acceptCheckoutHint = useCallback(() => {
if (!checkoutHintPrAttachment) return;
selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item });
}, [checkoutHintPrAttachment, selectPickerItem]);
const dismissCheckoutHint = useCallback(() => {
if (!checkoutHintPrAttachment) return;
const prNumber = checkoutHintPrAttachment.item.number;
setDismissedCheckoutHintPrNumbers((current) => {
if (current.has(prNumber)) return current;
const next = new Set(current);
next.add(prNumber);
return next;
});
}, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]);
const openPicker = useCallback(() => {
setPickerOpen(true);
}, []);
@@ -831,6 +930,15 @@ export function NewWorkspaceScreen({
renderOption={renderPickerOption}
/>
</View>
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
prNumber={checkoutHintPrAttachment.item.number}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
@@ -908,6 +1016,23 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
checkoutHintBadge: {
flexDirection: "row",
alignItems: "center",
height: 28,
maxWidth: 240,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
},
checkoutHintAction: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.full,
},
badgeHovered: {
backgroundColor: theme.colors.surface2,
},
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { extractGithubRefs, normalizeGithubRemote, parseGithubRef } from "./github-refs";
const httpsRemote = "https://github.com/getpaseo/paseo.git";
const sshRemote = "git@github.com:getpaseo/paseo.git";
describe("normalizeGithubRemote", () => {
it.each([
["https://github.com/getpaseo/paseo", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
[
"https://github.com/getpaseo/paseo.git",
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
],
["git@github.com:getpaseo/paseo.git", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
[
"ssh://git@github.com/getpaseo/paseo.git",
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
],
])("extracts GitHub identity from %s", (remoteUrl, expected) => {
expect(normalizeGithubRemote(remoteUrl)).toEqual(expected);
});
it("returns null for non-GitHub remotes and empty input", () => {
expect(normalizeGithubRemote("git@gitlab.com:getpaseo/paseo.git")).toBeNull();
expect(normalizeGithubRemote(null)).toBeNull();
});
});
describe("parseGithubRef", () => {
it.each([
"https://github.com/getpaseo/paseo/pull/994",
"https://github.com/getpaseo/paseo/pull/994/",
"https://github.com/getpaseo/paseo/pull/994/files",
"https://github.com/getpaseo/paseo/pull/994?diff=split",
"https://github.com/getpaseo/paseo/pull/994#discussion_r123",
])("parses a matching pull request URL: %s", (text) => {
expect(parseGithubRef(text, httpsRemote)).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
it("parses a matching issue URL", () => {
expect(parseGithubRef("https://github.com/getpaseo/paseo/issues/456", httpsRemote)).toEqual({
kind: "issues",
number: 456,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/issues/456",
});
});
it("matches HTTPS pasted URLs against an SSH remote", () => {
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", sshRemote)).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
it("ignores URLs for another owner or repo", () => {
expect(parseGithubRef("https://github.com/other/paseo/pull/994", httpsRemote)).toBeNull();
expect(parseGithubRef("https://github.com/getpaseo/other/pull/994", httpsRemote)).toBeNull();
});
it("returns null for non-GitHub remotes and empty text", () => {
expect(
parseGithubRef(
"https://github.com/getpaseo/paseo/pull/994",
"git@gitlab.com:getpaseo/paseo.git",
),
).toBeNull();
expect(parseGithubRef("", httpsRemote)).toBeNull();
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", null)).toBeNull();
});
it("finds URLs embedded in text and markdown links", () => {
expect(
parseGithubRef(
"See:\n[the PR](https://github.com/getpaseo/paseo/pull/994/files).",
httpsRemote,
),
).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
});
describe("extractGithubRefs", () => {
it("returns every matching ref deduped by kind and number", () => {
const text = [
"https://github.com/getpaseo/paseo/pull/994",
"https://github.com/getpaseo/paseo/issues/456#issuecomment-1",
"https://github.com/getpaseo/paseo/pull/994/files",
"https://github.com/other/paseo/issues/1",
].join("\n");
expect(extractGithubRefs(text, httpsRemote)).toEqual([
{
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
},
{
kind: "issues",
number: 456,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/issues/456",
},
]);
});
it("returns an empty array for empty text or null remote", () => {
expect(extractGithubRefs("", httpsRemote)).toEqual([]);
expect(extractGithubRefs("https://github.com/getpaseo/paseo/pull/994", null)).toEqual([]);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
export type GithubRefKind = "pull" | "issues";
export interface GithubRemote {
owner: string;
repo: string;
host: "github.com";
}
export interface GithubRef {
kind: GithubRefKind;
number: number;
owner: string;
repo: string;
url: string;
}
interface ParsedGithubUrl {
kind: GithubRefKind;
number: number;
owner: string;
repo: string;
}
const GITHUB_REF_URL_PATTERN =
/https?:\/\/github\.com\/([^/\s<>)\]]+)\/([^/\s<>)\]]+)\/(pull|issues)\/(\d+)(?:[/?#][^\s<>)\]]*)?/giu;
export function normalizeGithubRemote(remoteUrl: string | null | undefined): GithubRemote | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
const identity = parseGitHubRemoteUrl(trimmed);
if (!identity) {
return null;
}
return {
owner: identity.owner,
repo: identity.name,
host: "github.com",
};
}
export function parseGithubRef(
text: string | null | undefined,
remoteUrl: string | null | undefined,
): GithubRef | null {
return extractGithubRefs(text, remoteUrl)[0] ?? null;
}
export function extractGithubRefs(
text: string | null | undefined,
remoteUrl: string | null | undefined,
): GithubRef[] {
const remote = normalizeGithubRemote(remoteUrl);
const body = text?.trim();
if (!remote || !body) {
return [];
}
const refs: GithubRef[] = [];
const seen = new Set<string>();
for (const match of body.matchAll(GITHUB_REF_URL_PATTERN)) {
const parsed = parseGithubUrlMatch(match);
if (!parsed || !matchesRemote(parsed, remote)) {
continue;
}
const dedupeKey = `${parsed.kind}:${parsed.number}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
refs.push({
kind: parsed.kind,
number: parsed.number,
owner: remote.owner,
repo: remote.repo,
url: `https://github.com/${remote.owner}/${remote.repo}/${parsed.kind}/${parsed.number}`,
});
}
return refs;
}
function parseGithubUrlMatch(match: RegExpMatchArray): ParsedGithubUrl | null {
const owner = match[1];
const repo = match[2];
const kind = match[3];
const numberText = match[4];
if (!owner || !repo || !isGithubRefKind(kind) || !numberText) {
return null;
}
const number = Number.parseInt(numberText, 10);
if (!Number.isSafeInteger(number) || number <= 0) {
return null;
}
return { owner, repo, kind, number };
}
function isGithubRefKind(value: string): value is GithubRefKind {
return value === "pull" || value === "issues";
}
function matchesRemote(parsed: ParsedGithubUrl, remote: GithubRemote): boolean {
return (
parsed.owner.toLowerCase() === remote.owner.toLowerCase() &&
parsed.repo.toLowerCase() === remote.repo.toLowerCase()
);
}