diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 80a560ac2..61685cef1 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; @@ -41,8 +41,6 @@ interface NewWorkspaceScreenProps { interface PickerOptionData { options: ComboboxOptionType[]; itemById: Map; - firstBranchId: string | null; - firstPrId: string | null; } interface PickerSelection { @@ -119,8 +117,16 @@ export function NewWorkspaceScreen({ const [pendingAction, setPendingAction] = useState<"chat" | null>(null); const [pickerSelection, setPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); + const [pickerSearchQuery, setPickerSearchQuery] = useState(""); + const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); const pickerAnchorRef = useRef(null); + useEffect(() => { + const trimmed = pickerSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedPickerSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [pickerSearchQuery]); + const displayName = displayNameProp?.trim() ?? ""; const workspace = createdWorkspace; const selectedItem = pickerSelection?.item ?? null; @@ -161,31 +167,40 @@ export function NewWorkspaceScreen({ const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; const branchSuggestionsQuery = useQuery({ - queryKey: ["branch-suggestions", serverId, sourceDirectory], + queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery], queryFn: async () => { const connectedClient = withConnectedClient(); - return connectedClient.getBranchSuggestions({ cwd: sourceDirectory, limit: 20 }); + return connectedClient.getBranchSuggestions({ + cwd: sourceDirectory, + query: debouncedPickerSearchQuery || undefined, + limit: 20, + }); }, enabled: isConnected && !!client, staleTime: 15_000, }); const githubPrSearchQuery = useQuery({ - queryKey: ["new-workspace-github-prs", serverId, sourceDirectory], + queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery], queryFn: async () => { const connectedClient = withConnectedClient(); return connectedClient.searchGitHub({ cwd: sourceDirectory, - query: "", + query: debouncedPickerSearchQuery, limit: 20, kinds: ["github-pr"], }); }, enabled: isConnected && !!client, - staleTime: 60_000, + staleTime: 30_000, }); - const branchNames: string[] = branchSuggestionsQuery.data?.branches ?? []; + 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 githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false; const prItems: GitHubSearchItem[] = useMemo(() => { if (!githubFeaturesEnabled) return []; @@ -193,37 +208,35 @@ export function NewWorkspaceScreen({ 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 { options, itemById }: PickerOptionData = useMemo(() => { const idMap = new Map(); - 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; + interface TimedOption { + option: ComboboxOptionType; + timestamp: number; + } + const timedOptions: TimedOption[] = []; + + for (const branch of branchDetails) { + const id = branchOptionId(branch.name); + const option = { id, label: branch.name }; + idMap.set(id, { kind: "branch", name: branch.name }); + timedOptions.push({ option, timestamp: branch.committerDate }); } 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; + const option = { id, label: formatPrLabel(pr) }; + idMap.set(id, { kind: "github-pr", item: pr }); + const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0; + const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000); + timedOptions.push({ option, timestamp }); } - return { - options: collectedOptions, - itemById: idMap, - firstBranchId: firstBranch, - firstPrId: firstPr, - }; - }, [branchNames, prItems]); + timedOptions.sort((a, b) => b.timestamp - a.timestamp); + return { options: timedOptions.map((t) => t.option), itemById: idMap }; + }, [branchDetails, prItems]); const triggerLabel = useMemo(() => { if (selectedItem) return pickerItemTriggerLabel(selectedItem); @@ -383,12 +396,6 @@ export function NewWorkspaceScreen({ if (!item) return ; const isBranch = item.kind === "branch"; - const isFirstInSection = - (isBranch && option.id === firstBranchId) || (!isBranch && option.id === firstPrId); - - const header = isFirstInSection ? ( - {isBranch ? "Branches" : "Pull requests"} - ) : null; const leadingSlot = ( @@ -408,29 +415,19 @@ export function NewWorkspaceScreen({ !isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined; return ( - <> - {header} - - + ); }, - [ - firstBranchId, - firstPrId, - isPending, - itemById, - theme.colors.foregroundMuted, - theme.iconSize.sm, - ], + [isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm], ); return ( @@ -532,10 +529,20 @@ export function NewWorkspaceScreen({ searchPlaceholder="Search branches and PRs" title="Start from" open={pickerOpen} - onOpenChange={setPickerOpen} + onOpenChange={(nextOpen) => { + setPickerOpen(nextOpen); + if (!nextOpen) { + setPickerSearchQuery(""); + } + }} + onSearchQueryChange={setPickerSearchQuery} desktopPlacement="bottom-start" anchorRef={pickerAnchorRef} - emptyText="No matching refs." + emptyText={ + branchSuggestionsQuery.isFetching || githubPrSearchQuery.isFetching + ? "Searching..." + : "No matching refs." + } renderOption={renderPickerOption} /> @@ -644,13 +651,4 @@ 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, - }, })); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 84e2dcd9b..05d0f3da6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4088,11 +4088,12 @@ export class Session { try { const resolvedCwd = expandTilde(cwd); - const branches = await listBranchSuggestions(resolvedCwd, { query, limit }); + const branchDetails = await listBranchSuggestions(resolvedCwd, { query, limit }); this.emit({ type: "branch_suggestions_response", payload: { - branches, + branches: branchDetails.map((branch) => branch.name), + branchDetails, error: null, requestId, }, @@ -4102,6 +4103,7 @@ export class Session { type: "branch_suggestions_response", payload: { branches: [], + branchDetails: [], error: error instanceof Error ? error.message : String(error), requestId, }, diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index e23feb90f..5d6a42f63 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -20,6 +20,7 @@ const GitHubIssueSummarySchema = z.object({ state: z.string().catch(""), body: z.string().nullable().catch(null), labels: z.array(LabelSchema).catch([]), + updatedAt: z.string().catch(""), }); const GitHubPullRequestSummarySchema = z.object({ @@ -31,6 +32,7 @@ const GitHubPullRequestSummarySchema = z.object({ baseRefName: z.string().catch(""), headRefName: z.string().catch(""), labels: z.array(LabelSchema).catch([]), + updatedAt: z.string().catch(""), }); const PullRequestCheckRunNodeSchema = z.object({ @@ -123,6 +125,7 @@ export interface GitHubPullRequestSummary { baseRefName: string; headRefName: string; labels: string[]; + updatedAt: string; } export interface GitHubIssueSummary { @@ -132,6 +135,7 @@ export interface GitHubIssueSummary { state: string; body: string | null; labels: string[]; + updatedAt: string; } export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped"; @@ -337,7 +341,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G "--search", options.query ?? "", "--json", - "number,title,url,state,body,labels,baseRefName,headRefName", + "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", "--limit", String(options.limit ?? 20), ], @@ -361,7 +365,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G "--search", options.query ?? "", "--json", - "number,title,url,state,body,labels", + "number,title,url,state,body,labels,updatedAt", "--limit", String(options.limit ?? 20), ], @@ -384,7 +388,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G "view", String(options.number), "--json", - "number,title,url,state,body,labels,baseRefName,headRefName", + "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", ], { cwd: options.cwd }, ); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f36464b64..faa788577 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1189,6 +1189,7 @@ export const GitHubSearchItemSchema = z.object({ labels: z.array(z.string()), baseRefName: z.string().nullable().optional(), headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), }); export const GitHubSearchKindSchema = z.enum(["github-issue", "github-pr"]); @@ -2598,6 +2599,14 @@ export const BranchSuggestionsResponseSchema = z.object({ type: z.literal("branch_suggestions_response"), payload: z.object({ branches: z.array(z.string()), + branchDetails: z + .array( + z.object({ + name: z.string(), + committerDate: z.number(), + }), + ) + .optional(), error: z.string().nullable(), requestId: z.string(), }), diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 00423a38c..db3815168 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -765,12 +765,21 @@ const x = 1; execSync("git fetch origin", { cwd: repoDir }); const branches = await listBranchSuggestions(repoDir, { limit: 50 }); - expect(branches).toContain("main"); - expect(branches).toContain("local-only"); - expect(branches).toContain("remote-only"); - expect(branches.filter((name) => name === "main")).toHaveLength(1); - expect(branches).not.toContain("HEAD"); - expect(branches.some((name) => name.startsWith("origin/"))).toBe(false); + const branchNames = branches.map((branch) => branch.name); + expect(branchNames).toContain("main"); + expect(branchNames).toContain("local-only"); + expect(branchNames).toContain("remote-only"); + expect(branchNames.filter((name) => name === "main")).toHaveLength(1); + expect(branchNames).not.toContain("HEAD"); + expect(branchNames.some((name) => name.startsWith("origin/"))).toBe(false); + expect(branches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "main", + committerDate: expect.any(Number), + }), + ]), + ); }); it("filters branch suggestions by query and enforces result limit", async () => { @@ -786,7 +795,8 @@ const x = 1; limit: 1, }); expect(branches).toHaveLength(1); - expect(branches[0]?.toLowerCase()).toContain("feature/"); + expect(branches[0]?.name.toLowerCase()).toContain("feature/"); + expect(branches[0]?.committerDate).toEqual(expect.any(Number)); }); it("disables GitHub features when gh is unavailable", async () => { @@ -816,6 +826,7 @@ const x = 1; state: "OPEN", body: "issue body", labels: ["bug"], + updatedAt: "2026-04-18T12:00:00Z", }, ]; }; @@ -832,6 +843,7 @@ const x = 1; baseRefName: "main", headRefName: "feature", labels: ["enhancement"], + updatedAt: "2026-04-18T13:00:00Z", }, ]; }; @@ -853,6 +865,7 @@ const x = 1; labels: ["bug"], baseRefName: null, headRefName: null, + updatedAt: "2026-04-18T12:00:00Z", }, { kind: "pr", @@ -864,6 +877,7 @@ const x = 1; labels: ["enhancement"], baseRefName: "main", headRefName: "feature", + updatedAt: "2026-04-18T13:00:00Z", }, ], }); @@ -890,6 +904,7 @@ const x = 1; baseRefName: "main", headRefName: "feature", labels: ["enhancement"], + updatedAt: "2026-04-18T13:00:00Z", }, ]; }; @@ -913,6 +928,7 @@ const x = 1; labels: ["enhancement"], baseRefName: "main", headRefName: "feature", + updatedAt: "2026-04-18T13:00:00Z", }, ], }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 8d11b9b21..e6d877f37 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -128,6 +128,11 @@ interface GitRef { committerDate: number; } +export interface BranchSuggestion { + name: string; + committerDate: number; +} + async function listGitRefs(cwd: string, refPrefix: string): Promise { const { stdout } = await runGitCommand( [ @@ -181,7 +186,7 @@ function sortBranchSuggestions( export async function listBranchSuggestions( cwd: string, options?: { query?: string; limit?: number }, -): Promise { +): Promise { await requireGitRepo(cwd); const requestedLimit = options?.limit ?? 50; @@ -227,7 +232,10 @@ export async function listBranchSuggestions( } const ordered = sortBranchSuggestions(filteredNames, branchMeta, query); - return ordered.slice(0, limit); + return ordered.slice(0, limit).map((name) => ({ + name, + committerDate: branchMeta.get(name)?.committerDate ?? 0, + })); } async function listCheckoutFileChanges( @@ -1936,6 +1944,7 @@ export interface GitHubSearchResult { labels: string[]; baseRefName?: string | null; headRefName?: string | null; + updatedAt?: string; }>; githubFeaturesEnabled: boolean; } @@ -1997,6 +2006,7 @@ export async function searchGitHubIssuesAndPrs( labels: item.labels, baseRefName: null, headRefName: null, + updatedAt: item.updatedAt, }); } } @@ -2013,6 +2023,7 @@ export async function searchGitHubIssuesAndPrs( labels: item.labels, baseRefName: item.baseRefName, headRefName: item.headRefName, + updatedAt: item.updatedAt, }); } }