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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
@@ -41,8 +41,6 @@ interface NewWorkspaceScreenProps {
interface PickerOptionData {
options: ComboboxOptionType[];
itemById: Map<string, PickerItem>;
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<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
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 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<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;
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 <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}>
@@ -408,29 +415,19 @@ export function NewWorkspaceScreen({
!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}
/>
</>
<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,
],
[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}
/>
</View>
@@ -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,
},
}));