feat(app,server): rank new-workspace picker by recency with debounced search

Plumb updatedAt through GitHub issue/PR search and committerDate through
branch suggestions so the new-workspace picker interleaves branches and
PRs sorted by last activity. Debounce the search query so remote GitHub
searches are driven by what the user typed rather than a static list.
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:51 +07:00
parent 43bbf8cb12
commit d3393b434a
6 changed files with 123 additions and 83 deletions
@@ -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 { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
@@ -41,8 +41,6 @@ interface NewWorkspaceScreenProps {
interface PickerOptionData { interface PickerOptionData {
options: ComboboxOptionType[]; options: ComboboxOptionType[];
itemById: Map<string, PickerItem>; itemById: Map<string, PickerItem>;
firstBranchId: string | null;
firstPrId: string | null;
} }
interface PickerSelection { interface PickerSelection {
@@ -119,8 +117,16 @@ export function NewWorkspaceScreen({
const [pendingAction, setPendingAction] = useState<"chat" | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null); const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
const pickerAnchorRef = useRef<View>(null); const pickerAnchorRef = useRef<View>(null);
useEffect(() => {
const trimmed = pickerSearchQuery.trim();
const timer = setTimeout(() => setDebouncedPickerSearchQuery(trimmed), 180);
return () => clearTimeout(timer);
}, [pickerSearchQuery]);
const displayName = displayNameProp?.trim() ?? ""; const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace; const workspace = createdWorkspace;
const selectedItem = pickerSelection?.item ?? null; const selectedItem = pickerSelection?.item ?? null;
@@ -161,31 +167,40 @@ export function NewWorkspaceScreen({
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
const branchSuggestionsQuery = useQuery({ const branchSuggestionsQuery = useQuery({
queryKey: ["branch-suggestions", serverId, sourceDirectory], queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => { queryFn: async () => {
const connectedClient = withConnectedClient(); const connectedClient = withConnectedClient();
return connectedClient.getBranchSuggestions({ cwd: sourceDirectory, limit: 20 }); return connectedClient.getBranchSuggestions({
cwd: sourceDirectory,
query: debouncedPickerSearchQuery || undefined,
limit: 20,
});
}, },
enabled: isConnected && !!client, enabled: isConnected && !!client,
staleTime: 15_000, staleTime: 15_000,
}); });
const githubPrSearchQuery = useQuery({ const githubPrSearchQuery = useQuery({
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory], queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => { queryFn: async () => {
const connectedClient = withConnectedClient(); const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({ return connectedClient.searchGitHub({
cwd: sourceDirectory, cwd: sourceDirectory,
query: "", query: debouncedPickerSearchQuery,
limit: 20, limit: 20,
kinds: ["github-pr"], kinds: ["github-pr"],
}); });
}, },
enabled: isConnected && !!client, 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 githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
const prItems: GitHubSearchItem[] = useMemo(() => { const prItems: GitHubSearchItem[] = useMemo(() => {
if (!githubFeaturesEnabled) return []; if (!githubFeaturesEnabled) return [];
@@ -193,37 +208,35 @@ export function NewWorkspaceScreen({
return items.filter((item): item is GitHubSearchItem => item.kind === "pr"); return items.filter((item): item is GitHubSearchItem => item.kind === "pr");
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]); }, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
const { options, itemById, firstBranchId, firstPrId }: PickerOptionData = useMemo(() => { const { options, itemById }: PickerOptionData = useMemo(() => {
const collectedOptions: ComboboxOptionType[] = [];
const idMap = new Map<string, PickerItem>(); const idMap = new Map<string, PickerItem>();
let firstBranch: string | null = null;
let firstPr: string | null = null;
for (const name of branchNames) { interface TimedOption {
const id = branchOptionId(name); option: ComboboxOptionType;
collectedOptions.push({ id, label: name }); timestamp: number;
idMap.set(id, { kind: "branch", name }); }
if (firstBranch === null) firstBranch = id; 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) { for (const pr of prItems) {
if (!pr.headRefName) continue; if (!pr.headRefName) continue;
const id = prOptionId(pr.number); const id = prOptionId(pr.number);
collectedOptions.push({ id, label: formatPrLabel(pr) }); const option = { id, label: formatPrLabel(pr) };
idMap.set(id, { idMap.set(id, { kind: "github-pr", item: pr });
kind: "github-pr", const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
item: pr, const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
}); timedOptions.push({ option, timestamp });
if (firstPr === null) firstPr = id;
} }
return { timedOptions.sort((a, b) => b.timestamp - a.timestamp);
options: collectedOptions, return { options: timedOptions.map((t) => t.option), itemById: idMap };
itemById: idMap, }, [branchDetails, prItems]);
firstBranchId: firstBranch,
firstPrId: firstPr,
};
}, [branchNames, prItems]);
const triggerLabel = useMemo(() => { const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem); if (selectedItem) return pickerItemTriggerLabel(selectedItem);
@@ -383,12 +396,6 @@ export function NewWorkspaceScreen({
if (!item) return <View key={option.id} />; if (!item) return <View key={option.id} />;
const isBranch = item.kind === "branch"; 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 = ( const leadingSlot = (
<View style={styles.rowIconBox}> <View style={styles.rowIconBox}>
@@ -408,29 +415,19 @@ export function NewWorkspaceScreen({
!isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined; !isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined;
return ( return (
<> <ComboboxItem
{header} testID={testID}
<ComboboxItem label={pickerItemLabel(item)}
testID={testID} description={description}
label={pickerItemLabel(item)} selected={selected}
description={description} active={active}
selected={selected} disabled={isPending}
active={active} onPress={onPress}
disabled={isPending} leadingSlot={leadingSlot}
onPress={onPress} />
leadingSlot={leadingSlot}
/>
</>
); );
}, },
[ [isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm],
firstBranchId,
firstPrId,
isPending,
itemById,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
); );
return ( return (
@@ -532,10 +529,20 @@ export function NewWorkspaceScreen({
searchPlaceholder="Search branches and PRs" searchPlaceholder="Search branches and PRs"
title="Start from" title="Start from"
open={pickerOpen} open={pickerOpen}
onOpenChange={setPickerOpen} onOpenChange={(nextOpen) => {
setPickerOpen(nextOpen);
if (!nextOpen) {
setPickerSearchQuery("");
}
}}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start" desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef} anchorRef={pickerAnchorRef}
emptyText="No matching refs." emptyText={
branchSuggestionsQuery.isFetching || githubPrSearchQuery.isFetching
? "Searching..."
: "No matching refs."
}
renderOption={renderPickerOption} renderOption={renderPickerOption}
/> />
</View> </View>
@@ -644,13 +651,4 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "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,
},
})); }));
+4 -2
View File
@@ -4088,11 +4088,12 @@ export class Session {
try { try {
const resolvedCwd = expandTilde(cwd); const resolvedCwd = expandTilde(cwd);
const branches = await listBranchSuggestions(resolvedCwd, { query, limit }); const branchDetails = await listBranchSuggestions(resolvedCwd, { query, limit });
this.emit({ this.emit({
type: "branch_suggestions_response", type: "branch_suggestions_response",
payload: { payload: {
branches, branches: branchDetails.map((branch) => branch.name),
branchDetails,
error: null, error: null,
requestId, requestId,
}, },
@@ -4102,6 +4103,7 @@ export class Session {
type: "branch_suggestions_response", type: "branch_suggestions_response",
payload: { payload: {
branches: [], branches: [],
branchDetails: [],
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
requestId, requestId,
}, },
@@ -20,6 +20,7 @@ const GitHubIssueSummarySchema = z.object({
state: z.string().catch(""), state: z.string().catch(""),
body: z.string().nullable().catch(null), body: z.string().nullable().catch(null),
labels: z.array(LabelSchema).catch([]), labels: z.array(LabelSchema).catch([]),
updatedAt: z.string().catch(""),
}); });
const GitHubPullRequestSummarySchema = z.object({ const GitHubPullRequestSummarySchema = z.object({
@@ -31,6 +32,7 @@ const GitHubPullRequestSummarySchema = z.object({
baseRefName: z.string().catch(""), baseRefName: z.string().catch(""),
headRefName: z.string().catch(""), headRefName: z.string().catch(""),
labels: z.array(LabelSchema).catch([]), labels: z.array(LabelSchema).catch([]),
updatedAt: z.string().catch(""),
}); });
const PullRequestCheckRunNodeSchema = z.object({ const PullRequestCheckRunNodeSchema = z.object({
@@ -123,6 +125,7 @@ export interface GitHubPullRequestSummary {
baseRefName: string; baseRefName: string;
headRefName: string; headRefName: string;
labels: string[]; labels: string[];
updatedAt: string;
} }
export interface GitHubIssueSummary { export interface GitHubIssueSummary {
@@ -132,6 +135,7 @@ export interface GitHubIssueSummary {
state: string; state: string;
body: string | null; body: string | null;
labels: string[]; labels: string[];
updatedAt: string;
} }
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped"; export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
@@ -337,7 +341,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
"--search", "--search",
options.query ?? "", options.query ?? "",
"--json", "--json",
"number,title,url,state,body,labels,baseRefName,headRefName", "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit", "--limit",
String(options.limit ?? 20), String(options.limit ?? 20),
], ],
@@ -361,7 +365,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
"--search", "--search",
options.query ?? "", options.query ?? "",
"--json", "--json",
"number,title,url,state,body,labels", "number,title,url,state,body,labels,updatedAt",
"--limit", "--limit",
String(options.limit ?? 20), String(options.limit ?? 20),
], ],
@@ -384,7 +388,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
"view", "view",
String(options.number), String(options.number),
"--json", "--json",
"number,title,url,state,body,labels,baseRefName,headRefName", "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
], ],
{ cwd: options.cwd }, { cwd: options.cwd },
); );
+9
View File
@@ -1189,6 +1189,7 @@ export const GitHubSearchItemSchema = z.object({
labels: z.array(z.string()), labels: z.array(z.string()),
baseRefName: z.string().nullable().optional(), baseRefName: z.string().nullable().optional(),
headRefName: z.string().nullable().optional(), headRefName: z.string().nullable().optional(),
updatedAt: z.string().optional(),
}); });
export const GitHubSearchKindSchema = z.enum(["github-issue", "github-pr"]); export const GitHubSearchKindSchema = z.enum(["github-issue", "github-pr"]);
@@ -2598,6 +2599,14 @@ export const BranchSuggestionsResponseSchema = z.object({
type: z.literal("branch_suggestions_response"), type: z.literal("branch_suggestions_response"),
payload: z.object({ payload: z.object({
branches: z.array(z.string()), branches: z.array(z.string()),
branchDetails: z
.array(
z.object({
name: z.string(),
committerDate: z.number(),
}),
)
.optional(),
error: z.string().nullable(), error: z.string().nullable(),
requestId: z.string(), requestId: z.string(),
}), }),
+23 -7
View File
@@ -765,12 +765,21 @@ const x = 1;
execSync("git fetch origin", { cwd: repoDir }); execSync("git fetch origin", { cwd: repoDir });
const branches = await listBranchSuggestions(repoDir, { limit: 50 }); const branches = await listBranchSuggestions(repoDir, { limit: 50 });
expect(branches).toContain("main"); const branchNames = branches.map((branch) => branch.name);
expect(branches).toContain("local-only"); expect(branchNames).toContain("main");
expect(branches).toContain("remote-only"); expect(branchNames).toContain("local-only");
expect(branches.filter((name) => name === "main")).toHaveLength(1); expect(branchNames).toContain("remote-only");
expect(branches).not.toContain("HEAD"); expect(branchNames.filter((name) => name === "main")).toHaveLength(1);
expect(branches.some((name) => name.startsWith("origin/"))).toBe(false); 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 () => { it("filters branch suggestions by query and enforces result limit", async () => {
@@ -786,7 +795,8 @@ const x = 1;
limit: 1, limit: 1,
}); });
expect(branches).toHaveLength(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 () => { it("disables GitHub features when gh is unavailable", async () => {
@@ -816,6 +826,7 @@ const x = 1;
state: "OPEN", state: "OPEN",
body: "issue body", body: "issue body",
labels: ["bug"], labels: ["bug"],
updatedAt: "2026-04-18T12:00:00Z",
}, },
]; ];
}; };
@@ -832,6 +843,7 @@ const x = 1;
baseRefName: "main", baseRefName: "main",
headRefName: "feature", headRefName: "feature",
labels: ["enhancement"], labels: ["enhancement"],
updatedAt: "2026-04-18T13:00:00Z",
}, },
]; ];
}; };
@@ -853,6 +865,7 @@ const x = 1;
labels: ["bug"], labels: ["bug"],
baseRefName: null, baseRefName: null,
headRefName: null, headRefName: null,
updatedAt: "2026-04-18T12:00:00Z",
}, },
{ {
kind: "pr", kind: "pr",
@@ -864,6 +877,7 @@ const x = 1;
labels: ["enhancement"], labels: ["enhancement"],
baseRefName: "main", baseRefName: "main",
headRefName: "feature", headRefName: "feature",
updatedAt: "2026-04-18T13:00:00Z",
}, },
], ],
}); });
@@ -890,6 +904,7 @@ const x = 1;
baseRefName: "main", baseRefName: "main",
headRefName: "feature", headRefName: "feature",
labels: ["enhancement"], labels: ["enhancement"],
updatedAt: "2026-04-18T13:00:00Z",
}, },
]; ];
}; };
@@ -913,6 +928,7 @@ const x = 1;
labels: ["enhancement"], labels: ["enhancement"],
baseRefName: "main", baseRefName: "main",
headRefName: "feature", headRefName: "feature",
updatedAt: "2026-04-18T13:00:00Z",
}, },
], ],
}); });
+13 -2
View File
@@ -128,6 +128,11 @@ interface GitRef {
committerDate: number; committerDate: number;
} }
export interface BranchSuggestion {
name: string;
committerDate: number;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> { async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
const { stdout } = await runGitCommand( const { stdout } = await runGitCommand(
[ [
@@ -181,7 +186,7 @@ function sortBranchSuggestions(
export async function listBranchSuggestions( export async function listBranchSuggestions(
cwd: string, cwd: string,
options?: { query?: string; limit?: number }, options?: { query?: string; limit?: number },
): Promise<string[]> { ): Promise<BranchSuggestion[]> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
const requestedLimit = options?.limit ?? 50; const requestedLimit = options?.limit ?? 50;
@@ -227,7 +232,10 @@ export async function listBranchSuggestions(
} }
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query); 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( async function listCheckoutFileChanges(
@@ -1936,6 +1944,7 @@ export interface GitHubSearchResult {
labels: string[]; labels: string[];
baseRefName?: string | null; baseRefName?: string | null;
headRefName?: string | null; headRefName?: string | null;
updatedAt?: string;
}>; }>;
githubFeaturesEnabled: boolean; githubFeaturesEnabled: boolean;
} }
@@ -1997,6 +2006,7 @@ export async function searchGitHubIssuesAndPrs(
labels: item.labels, labels: item.labels,
baseRefName: null, baseRefName: null,
headRefName: null, headRefName: null,
updatedAt: item.updatedAt,
}); });
} }
} }
@@ -2013,6 +2023,7 @@ export async function searchGitHubIssuesAndPrs(
labels: item.labels, labels: item.labels,
baseRefName: item.baseRefName, baseRefName: item.baseRefName,
headRefName: item.headRefName, headRefName: item.headRefName,
updatedAt: item.updatedAt,
}); });
} }
} }